From 3eb7961137bd2afb1d4ec15c1269b99e5629a6f1 Mon Sep 17 00:00:00 2001 From: Seena Fallah Date: Sat, 11 Jul 2026 22:50:34 +0000 Subject: [PATCH] feat(webhooks): emit page created/deleted webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace webhooks could subscribe to project, issue, cycle, module and issue_comment events but not pages. Add a `page` event so subscribers are notified when a page is created or deleted. - db: add `Webhook.page` BooleanField (default False) + migration 0122_webhook_page. - bgtasks/webhook_task: register `page` in SERIALIZER_MAPPER and MODEL_MAPPER and route `event="page"` to page-subscribed webhooks. Add a read-only api PageSerializer for the payload (excludes the Yjs `description_binary` BinaryField, which has no JSON representation). - app page viewset: dispatch `webhook_activity.delay(event="page", ...)` on create ("created"), destroy ("deleted") and duplicate ("created"), mirroring the project viewset pattern. Duplication is a real create path, so it fires a created event too. - app webhook endpoint: add `page` to the GET/PATCH field enumerations so the new flag is listed alongside its peers (the app WebhookSerializer already exposes it via fields="__all__"). Out of scope (deliberately): page "updated" webhooks and `page_comment` — page edits flow through the Yjs/live collab server (not a DRF save) and there is no PageComment model. Tests: unit coverage for the page routing + serializer in webhook_activity, and contract coverage for create/destroy/duplicate dispatch and for provisioning the `page` flag through the webhook API. Signed-off-by: Seena Fallah --- apps/api/plane/api/serializers/__init__.py | 1 + apps/api/plane/api/serializers/page.py | 42 +++++ apps/api/plane/app/views/page/base.py | 46 +++++ apps/api/plane/app/views/webhook/base.py | 3 + apps/api/plane/bgtasks/webhook_task.py | 9 +- .../plane/db/migrations/0122_webhook_page.py | 17 ++ apps/api/plane/db/models/webhook.py | 1 + .../plane/tests/contract/app/test_page_app.py | 172 ++++++++++++++++++ .../tests/contract/app/test_webhook_app.py | 62 +++++++ .../unit/bg_tasks/test_webhook_page_task.py | 159 ++++++++++++++++ 10 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 apps/api/plane/api/serializers/page.py create mode 100644 apps/api/plane/db/migrations/0122_webhook_page.py create mode 100644 apps/api/plane/tests/contract/app/test_page_app.py create mode 100644 apps/api/plane/tests/contract/app/test_webhook_app.py create mode 100644 apps/api/plane/tests/unit/bg_tasks/test_webhook_page_task.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 2ab639d5466..9776edbabe7 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -64,3 +64,4 @@ from .invite import WorkspaceInviteSerializer from .member import ProjectMemberSerializer from .sticky import StickySerializer +from .page import PageSerializer diff --git a/apps/api/plane/api/serializers/page.py b/apps/api/plane/api/serializers/page.py new file mode 100644 index 00000000000..694fdf88af4 --- /dev/null +++ b/apps/api/plane/api/serializers/page.py @@ -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 diff --git a/apps/api/plane/app/views/page/base.py b/apps/api/plane/app/views/page/base.py index ec391afc1aa..82dfa990cc8 100644 --- a/apps/api/plane/app/views/page/base.py +++ b/apps/api/plane/app/views/page/base.py @@ -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): @@ -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) @@ -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, @@ -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, diff --git a/apps/api/plane/app/views/webhook/base.py b/apps/api/plane/app/views/webhook/base.py index 24496c742bf..7887005ca7a 100644 --- a/apps/api/plane/app/views/webhook/base.py +++ b/apps/api/plane/app/views/webhook/base.py @@ -52,6 +52,7 @@ def get(self, request, slug, pk=None): "cycle", "module", "issue_comment", + "page", ), many=True, ) @@ -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) @@ -94,6 +96,7 @@ def patch(self, request, slug, pk): "cycle", "module", "issue_comment", + "page", ), ) if serializer.is_valid(): diff --git a/apps/api/plane/bgtasks/webhook_task.py b/apps/api/plane/bgtasks/webhook_task.py index 5184ae6162a..13849272b6b 100644 --- a/apps/api/plane/bgtasks/webhook_task.py +++ b/apps/api/plane/bgtasks/webhook_task.py @@ -30,6 +30,7 @@ IssueExpandSerializer, ModuleIssueSerializer, ModuleSerializer, + PageSerializer, ProjectSerializer, UserLiteSerializer, IntakeIssueSerializer, @@ -41,6 +42,7 @@ IssueComment, Module, ModuleIssue, + Page, Project, User, Webhook, @@ -63,6 +65,7 @@ "cycle_issue": CycleIssueSerializer, "module_issue": ModuleIssueSerializer, "issue_comment": IssueCommentSerializer, + "page": PageSerializer, "user": UserLiteSerializer, "intake_issue": IntakeIssueSerializer, } @@ -75,6 +78,7 @@ "cycle_issue": CycleIssue, "module_issue": ModuleIssue, "issue_comment": IssueComment, + "page": Page, "user": User, "intake_issue": IntakeIssue, } @@ -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 @@ -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, diff --git a/apps/api/plane/db/migrations/0122_webhook_page.py b/apps/api/plane/db/migrations/0122_webhook_page.py new file mode 100644 index 00000000000..3f9d94856da --- /dev/null +++ b/apps/api/plane/db/migrations/0122_webhook_page.py @@ -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), + ), + ] diff --git a/apps/api/plane/db/models/webhook.py b/apps/api/plane/db/models/webhook.py index 99431ed4225..e19e1ab63f0 100644 --- a/apps/api/plane/db/models/webhook.py +++ b/apps/api/plane/db/models/webhook.py @@ -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) diff --git a/apps/api/plane/tests/contract/app/test_page_app.py b/apps/api/plane/tests/contract/app/test_page_app.py new file mode 100644 index 00000000000..ddbbfa547ba --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_page_app.py @@ -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() diff --git a/apps/api/plane/tests/contract/app/test_webhook_app.py b/apps/api/plane/tests/contract/app/test_webhook_app.py new file mode 100644 index 00000000000..4a5a6e8cc06 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_webhook_app.py @@ -0,0 +1,62 @@ +# 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 rest_framework import status + +from plane.db.models import Webhook + + +def webhooks_url(slug, pk=None): + base = f"/api/workspaces/{slug}/webhooks/" + return f"{base}{pk}/" if pk else base + + +@pytest.mark.contract +class TestWebhookPageField: + """The workspace webhook API must let callers provision the ``page`` flag.""" + + @pytest.fixture(autouse=True) + def _no_ssrf(self): + # The create/update path runs an SSRF check that resolves DNS; stub it so + # the test stays hermetic and focused on the `page` flag wiring. + with mock.patch("plane.app.serializers.webhook.validate_url"): + yield + + @pytest.mark.django_db + def test_create_webhook_with_page_flag(self, session_client, workspace): + response = session_client.post( + webhooks_url(workspace.slug), + {"url": "https://example.com/hook", "page": True}, + format="json", + ) + + assert response.status_code == status.HTTP_201_CREATED, f"Got {response.status_code}: {response.data!r}" + assert response.data["page"] is True + webhook = Webhook.objects.get(id=response.data["id"]) + assert webhook.page is True + + @pytest.mark.django_db + def test_get_and_patch_page_flag(self, session_client, workspace): + webhook = Webhook.objects.create( + workspace=workspace, + url="https://example.com/hook", + page=False, + ) + detail = webhooks_url(workspace.slug, webhook.id) + + # GET must expose the page flag. + get_resp = session_client.get(detail) + assert get_resp.status_code == status.HTTP_200_OK + assert get_resp.data["page"] is False + + # PATCH must be able to toggle it. + patch_resp = session_client.patch(detail, {"page": True}, format="json") + assert patch_resp.status_code == status.HTTP_200_OK + assert patch_resp.data["page"] is True + + webhook.refresh_from_db() + assert webhook.page is True diff --git a/apps/api/plane/tests/unit/bg_tasks/test_webhook_page_task.py b/apps/api/plane/tests/unit/bg_tasks/test_webhook_page_task.py new file mode 100644 index 00000000000..a8ab76faea5 --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_webhook_page_task.py @@ -0,0 +1,159 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from unittest.mock import patch +from uuid import uuid4 + +import pytest + +from plane.bgtasks.webhook_task import ( + MODEL_MAPPER, + SERIALIZER_MAPPER, + get_model_data, + webhook_activity, +) +from plane.db.models import Page, Webhook + + +@pytest.fixture +def page(db, workspace, create_user): + """A minimal page owned by the test user.""" + return Page.objects.create( + workspace=workspace, + owned_by=create_user, + name="Release Notes", + created_by=create_user, + updated_by=create_user, + ) + + +@pytest.mark.unit +class TestPageWebhookMapping: + """The webhook fan-out must know how to serialize a ``page`` event.""" + + def test_page_registered_in_mappers(self): + assert "page" in MODEL_MAPPER + assert MODEL_MAPPER["page"] is Page + assert "page" in SERIALIZER_MAPPER + + def test_get_model_data_serializes_page(self, page): + data = get_model_data(event="page", event_id=page.id) + assert str(data["id"]) == str(page.id) + assert data["name"] == "Release Notes" + # Pin the payload contract: the identifying + metadata fields are present. + expected_keys = { + "id", + "name", + "owned_by", + "access", + "is_locked", + "workspace", + "created_at", + "updated_at", + } + assert expected_keys <= set(data), f"missing keys: {expected_keys - set(data)}" + # The Yjs binary blob is never part of the webhook contract. + assert "description_binary" not in data + assert "description_json" not in data + + +@pytest.mark.unit +class TestPageWebhookActivity: + """``webhook_activity`` must route ``page`` events to page-subscribed webhooks.""" + + @pytest.fixture + def page_webhook(self, workspace): + return Webhook.objects.create( + workspace=workspace, + url="https://example.com/page-hook", + page=True, + ) + + @pytest.fixture + def other_webhook(self, workspace): + """A webhook that subscribes to issues but not pages.""" + return Webhook.objects.create( + workspace=workspace, + url="https://example.com/issue-hook", + issue=True, + page=False, + ) + + @patch("plane.bgtasks.webhook_task.webhook_send_task") + def test_created_dispatches_only_to_page_webhooks( + self, mock_send_task, page_webhook, other_webhook, page, create_user, workspace + ): + webhook_activity( + event="page", + verb="created", + field=None, + old_value=None, + new_value=None, + actor_id=create_user.id, + slug=workspace.slug, + current_site="http://localhost", + event_id=page.id, + old_identifier=None, + new_identifier=None, + ) + + mock_send_task.delay.assert_called_once() + kwargs = mock_send_task.delay.call_args.kwargs + assert kwargs["webhook_id"] == page_webhook.id + assert kwargs["event"] == "page" + assert kwargs["action"] == "created" + # On create the full serialized page travels in the payload. + assert str(kwargs["event_data"]["id"]) == str(page.id) + assert kwargs["event_data"]["name"] == "Release Notes" + + @patch("plane.bgtasks.webhook_task.webhook_send_task") + def test_deleted_sends_only_the_id(self, mock_send_task, page_webhook, create_user, workspace): + # A delete fires after the row is gone, so the fan-out never reloads the + # page — any id stands in for the already-deleted page here. + deleted_page_id = uuid4() + + webhook_activity( + event="page", + verb="deleted", + field=None, + old_value=None, + new_value=None, + actor_id=create_user.id, + slug=workspace.slug, + current_site="http://localhost", + event_id=deleted_page_id, + old_identifier=None, + new_identifier=None, + ) + + mock_send_task.delay.assert_called_once() + kwargs = mock_send_task.delay.call_args.kwargs + assert kwargs["action"] == "deleted" + # A delete cannot serialize a gone row — only the id is sent. + assert kwargs["event_data"] == {"id": deleted_page_id} + + @patch("plane.bgtasks.webhook_task.webhook_send_task") + def test_inactive_webhook_is_skipped(self, mock_send_task, page, create_user, workspace): + Webhook.objects.create( + workspace=workspace, + url="https://example.com/inactive-hook", + page=True, + is_active=False, + ) + + webhook_activity( + event="page", + verb="created", + field=None, + old_value=None, + new_value=None, + actor_id=create_user.id, + slug=workspace.slug, + current_site="http://localhost", + event_id=page.id, + old_identifier=None, + new_identifier=None, + ) + + mock_send_task.delay.assert_not_called()