Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .sticky import StickySerializer
from .page import PageSerializer
42 changes: 42 additions & 0 deletions apps/api/plane/api/serializers/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

# Module imports
from .base import BaseSerializer
from plane.db.models import Page


class PageSerializer(BaseSerializer):
"""Serializer used to build the webhook payload for page events.

Read-only projection of a :class:`~plane.db.models.Page`. ``description_binary``
is intentionally excluded — it is a ``BinaryField`` (the Yjs document) that has
no JSON representation and is not part of the public webhook contract.
"""

class Meta:
model = Page
fields = [
"id",
"name",
"description_html",
"description_stripped",
"owned_by",
"access",
"color",
"parent",
"archived_at",
"is_locked",
"is_global",
"view_props",
"logo_props",
"external_id",
"external_source",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = fields
46 changes: 46 additions & 0 deletions apps/api/plane/app/views/page/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@
from plane.bgtasks.page_version_task import track_page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
from plane.bgtasks.webhook_task import webhook_activity
from plane.app.permissions import ProjectPagePermission
from plane.utils.host import base_host


def unarchive_archive_page_and_descendants(page_id, archived_at):
Expand Down Expand Up @@ -146,6 +148,20 @@ def create(self, request, slug, project_id):
old_description_html=None,
page_id=serializer.data["id"],
)
# Dispatch the webhook for the page creation
webhook_activity.delay(
event="page",
verb="created",
field=None,
old_value=None,
new_value=None,
actor_id=request.user.id,
slug=slug,
current_site=base_host(request=request, is_app=True),
event_id=serializer.data["id"],
old_identifier=None,
new_identifier=None,
)
page = self.get_queryset().get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -402,6 +418,20 @@ def destroy(self, request, slug, project_id, page_id):
).update(parent=None)

page.delete()
# Dispatch the webhook for the page deletion
webhook_activity.delay(
event="page",
verb="deleted",
field=None,
old_value=None,
new_value=None,
actor_id=request.user.id,
slug=slug,
current_site=base_host(request=request, is_app=True),
event_id=page.id,
old_identifier=None,
new_identifier=None,
)
# Delete the user favorite page
UserFavorite.objects.filter(
project=project_id,
Expand Down Expand Up @@ -610,6 +640,22 @@ def post(self, request, slug, project_id, page_id):
updated_by_id=page.updated_by_id,
)

# Duplicating a page creates a new page, so it fires a "created" webhook
# just like PageViewSet.create does.
webhook_activity.delay(
event="page",
verb="created",
field=None,
old_value=None,
new_value=None,
actor_id=request.user.id,
slug=slug,
current_site=base_host(request=request, is_app=True),
event_id=page.id,
old_identifier=None,
new_identifier=None,
)

page_transaction.delay(
new_description_html=page.description_html,
old_description_html=None,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/plane/app/views/webhook/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def get(self, request, slug, pk=None):
"cycle",
"module",
"issue_comment",
"page",
),
many=True,
)
Expand All @@ -71,6 +72,7 @@ def get(self, request, slug, pk=None):
"cycle",
"module",
"issue_comment",
"page",
),
)
return Response(serializer.data, status=status.HTTP_200_OK)
Expand All @@ -94,6 +96,7 @@ def patch(self, request, slug, pk):
"cycle",
"module",
"issue_comment",
"page",
),
)
if serializer.is_valid():
Expand Down
9 changes: 8 additions & 1 deletion apps/api/plane/bgtasks/webhook_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
IssueExpandSerializer,
ModuleIssueSerializer,
ModuleSerializer,
PageSerializer,
ProjectSerializer,
UserLiteSerializer,
IntakeIssueSerializer,
Expand All @@ -41,6 +42,7 @@
IssueComment,
Module,
ModuleIssue,
Page,
Project,
User,
Webhook,
Expand All @@ -63,6 +65,7 @@
"cycle_issue": CycleIssueSerializer,
"module_issue": ModuleIssueSerializer,
"issue_comment": IssueCommentSerializer,
"page": PageSerializer,
"user": UserLiteSerializer,
"intake_issue": IntakeIssueSerializer,
}
Expand All @@ -75,6 +78,7 @@
"cycle_issue": CycleIssue,
"module_issue": ModuleIssue,
"issue_comment": IssueComment,
"page": Page,
"user": User,
"intake_issue": IntakeIssue,
}
Expand Down Expand Up @@ -410,7 +414,7 @@ def webhook_activity(
to all active webhooks for the workspace.

Args:
event (str): Type of event (project, issue, module, cycle, issue_comment)
event (str): Type of event (project, issue, module, cycle, issue_comment, page)
verb (str): Action performed (created, updated, deleted)
field (Optional[str]): Name of the field that was changed
old_value (Any): Previous value of the field
Expand Down Expand Up @@ -447,6 +451,9 @@ def webhook_activity(
if event == "issue_comment":
webhooks = webhooks.filter(issue_comment=True)

if event == "page":
webhooks = webhooks.filter(page=True)

for webhook in webhooks:
webhook_send_task.delay(
webhook_id=webhook.id,
Expand Down
17 changes: 17 additions & 0 deletions apps/api/plane/db/migrations/0122_webhook_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.30

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("db", "0121_alter_estimate_type"),
]

operations = [
migrations.AddField(
model_name="webhook",
name="page",
field=models.BooleanField(default=False),
),
]
1 change: 1 addition & 0 deletions apps/api/plane/db/models/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class Webhook(BaseModel):
module = models.BooleanField(default=False)
cycle = models.BooleanField(default=False)
issue_comment = models.BooleanField(default=False)
page = models.BooleanField(default=False)
is_internal = models.BooleanField(default=False)
version = models.CharField(default="v1", max_length=50)

Expand Down
172 changes: 172 additions & 0 deletions apps/api/plane/tests/contract/app/test_page_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from unittest import mock

import pytest
from django.utils import timezone
from rest_framework import status

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


@pytest.fixture
def project(db, workspace, create_user):
"""A project in the test workspace with the test user as an active admin."""
project = Project.objects.create(name="Docs", identifier="DOCS", workspace=workspace)
ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True)
return project


def pages_url(slug, project_id, page_id=None):
base = f"/api/workspaces/{slug}/projects/{project_id}/pages/"
return f"{base}{page_id}/" if page_id else base


@pytest.mark.contract
class TestPageWebhookDispatch:
"""Creating or deleting a page through the app API fires a ``page`` webhook."""

@pytest.fixture(autouse=True)
def _web_url(self, settings):
# base_host() needs an origin to build current_site; the value is
# irrelevant to the assertions but must be a valid URL.
settings.WEB_URL = "http://localhost"

@pytest.mark.django_db
def test_create_dispatches_page_webhook(self, session_client, workspace, project):
with (
mock.patch("plane.app.views.page.base.webhook_activity") as mocked_webhook,
mock.patch("plane.app.views.page.base.page_transaction"),
):
response = session_client.post(
pages_url(workspace.slug, project.id),
{"name": "Runbook"},
format="json",
)

assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
page_id = response.data["id"]
assert Page.objects.filter(id=page_id).exists()
assert ProjectPage.objects.filter(page_id=page_id, project=project).exists()

mocked_webhook.delay.assert_called_once()
kwargs = mocked_webhook.delay.call_args.kwargs
assert kwargs["event"] == "page"
assert kwargs["verb"] == "created"
assert str(kwargs["event_id"]) == str(page_id)
assert kwargs["slug"] == workspace.slug

@pytest.mark.django_db
def test_create_does_not_dispatch_on_invalid_payload(self, session_client, workspace, project):
"""A failed create (serializer invalid) must not fire a webhook."""
with (
mock.patch("plane.app.views.page.base.webhook_activity") as mocked_webhook,
mock.patch("plane.app.views.page.base.page_transaction"),
):
response = session_client.post(
pages_url(workspace.slug, project.id),
{"access": "not-an-integer"},
format="json",
)

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert not Page.objects.exists()
mocked_webhook.delay.assert_not_called()

@pytest.mark.django_db
def test_destroy_dispatches_page_webhook(self, session_client, workspace, project, create_user):
page = Page.objects.create(
workspace=workspace,
owned_by=create_user,
name="Old Page",
archived_at=timezone.now(),
created_by=create_user,
updated_by=create_user,
)
ProjectPage.objects.create(
workspace=workspace,
project=project,
page=page,
created_by=create_user,
updated_by=create_user,
)

with (
mock.patch("plane.app.views.page.base.webhook_activity") as mocked_webhook,
# page.delete() soft-deletes and enqueues a cascade task; stub it so
# the test has no hidden broker dependency.
mock.patch("plane.db.mixins.soft_delete_related_objects"),
):
response = session_client.delete(pages_url(workspace.slug, project.id, page.id))

assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}"
assert not Page.objects.filter(id=page.id).exists()

mocked_webhook.delay.assert_called_once()
kwargs = mocked_webhook.delay.call_args.kwargs
assert kwargs["event"] == "page"
assert kwargs["verb"] == "deleted"
assert str(kwargs["event_id"]) == str(page.id)

@pytest.mark.django_db
def test_duplicate_dispatches_page_webhook(self, session_client, workspace, project, create_user):
page = Page.objects.create(
workspace=workspace,
owned_by=create_user,
name="Template",
created_by=create_user,
updated_by=create_user,
)
ProjectPage.objects.create(
workspace=workspace,
project=project,
page=page,
created_by=create_user,
updated_by=create_user,
)

url = pages_url(workspace.slug, project.id, page.id) + "duplicate/"
with (
mock.patch("plane.app.views.page.base.webhook_activity") as mocked_webhook,
mock.patch("plane.app.views.page.base.page_transaction"),
mock.patch("plane.app.views.page.base.copy_s3_objects_of_description_and_assets"),
):
response = session_client.post(url, {}, format="json")

assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}"
new_page_id = response.data["id"]
# The duplicate is a distinct new page.
assert str(new_page_id) != str(page.id)

mocked_webhook.delay.assert_called_once()
kwargs = mocked_webhook.delay.call_args.kwargs
assert kwargs["event"] == "page"
assert kwargs["verb"] == "created"
assert str(kwargs["event_id"]) == str(new_page_id)

@pytest.mark.django_db
def test_destroy_requires_archived_page(self, session_client, workspace, project, create_user):
"""A non-archived page cannot be deleted, so no webhook fires."""
page = Page.objects.create(
workspace=workspace,
owned_by=create_user,
name="Live Page",
created_by=create_user,
updated_by=create_user,
)
ProjectPage.objects.create(
workspace=workspace,
project=project,
page=page,
created_by=create_user,
updated_by=create_user,
)

with mock.patch("plane.app.views.page.base.webhook_activity") as mocked_webhook:
response = session_client.delete(pages_url(workspace.slug, project.id, page.id))

assert response.status_code == status.HTTP_400_BAD_REQUEST
assert Page.objects.filter(id=page.id).exists()
mocked_webhook.delay.assert_not_called()
Loading