From aba2118b644832527ba89aa9a014f979d556d5e9 Mon Sep 17 00:00:00 2001 From: Nova Date: Mon, 20 Jul 2026 21:19:40 +0200 Subject: [PATCH] fix(bgtasks): track and subscribe inline assignees for external API issue creation create_issue_activity only ran track_assignees when the payload contained assignee_ids (the web app field name). Work items created through the external REST API (/api/v1/...) carry assignees instead, so inline assignees on create produced no assignee activity and were never auto-subscribed to the work item - meaning they got no notification for the assignment or any later change. track_assignees itself already reads both keys via extract_ids, as does the update path (both field names are mapped); only the create gate was missing the external API key. Accept both assignee_ids and assignees in the create gate. Unit tests added for both payload shapes and the no-assignees case. --- .../plane/bgtasks/issue_activities_task.py | 2 +- .../bg_tasks/test_issue_activities_task.py | 116 ++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 apps/api/plane/tests/unit/bg_tasks/test_issue_activities_task.py diff --git a/apps/api/plane/bgtasks/issue_activities_task.py b/apps/api/plane/bgtasks/issue_activities_task.py index 032feb02a60..a2a85a604a0 100644 --- a/apps/api/plane/bgtasks/issue_activities_task.py +++ b/apps/api/plane/bgtasks/issue_activities_task.py @@ -578,7 +578,7 @@ def create_issue_activity( issue_activity.actor_id = issue.created_by_id issue_activity.save(update_fields=["created_at", "actor_id"]) requested_data = json.loads(requested_data) if requested_data is not None else None - if requested_data.get("assignee_ids") is not None: + if requested_data.get("assignee_ids") is not None or requested_data.get("assignees") is not None: track_assignees( requested_data, current_instance, diff --git a/apps/api/plane/tests/unit/bg_tasks/test_issue_activities_task.py b/apps/api/plane/tests/unit/bg_tasks/test_issue_activities_task.py new file mode 100644 index 00000000000..92299d50f5f --- /dev/null +++ b/apps/api/plane/tests/unit/bg_tasks/test_issue_activities_task.py @@ -0,0 +1,116 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +""" +Unit tests for the issue activity background task. + +Verifies that creating a work item with inline assignees tracks the assignee +activities and auto-subscribes the assignees regardless of whether the payload +uses the web app field name (``assignee_ids``) or the external REST API field +name (``assignees``). With only ``assignee_ids`` accepted, work items created +through the external API with assignees produced no assignee activity and no +subscription, so the assignees were never notified. +""" + +import json + +import pytest +from django.utils import timezone + +from plane.bgtasks.issue_activities_task import create_issue_activity +from plane.db.models import Issue, IssueActivity, IssueSubscriber, User +from plane.tests.factories import ProjectFactory, WorkspaceFactory + + +@pytest.fixture +def workspace(db): + return WorkspaceFactory() + + +@pytest.fixture +def project(db, workspace): + return ProjectFactory(workspace=workspace) + + +@pytest.fixture +def assignee(db): + # Not UserFactory: it leaves username empty, and a second factory user + # would collide with the unique username constraint. + user = User.objects.create( + email="assignee@plane.so", + username="assignee-user", + first_name="Assignee", + last_name="User", + ) + user.set_password("assignee-password") + user.save() + return user + + +@pytest.fixture +def author(db): + user = User.objects.create( + email="author@plane.so", + username="author-user", + first_name="Author", + last_name="User", + ) + user.set_password("author-password") + user.save() + return user + + +@pytest.fixture +def issue(db, workspace, project, author): + return Issue.objects.create( + name="Created via external API", + project=project, + workspace=workspace, + created_by=author, + ) + + +@pytest.mark.unit +class TestCreateIssueActivityAssignees: + def _run(self, issue, project, workspace, actor, requested_data): + # actor is passed explicitly: BaseModel.save() derives created_by from + # crum's current user, which is None outside a request, so + # issue.created_by_id cannot be used as the actor here. + issue_activities = [] + create_issue_activity( + requested_data=json.dumps(requested_data), + current_instance=None, + issue_id=issue.id, + project_id=project.id, + workspace_id=workspace.id, + actor_id=str(actor.id), + issue_activities=issue_activities, + epoch=int(timezone.now().timestamp()), + ) + return issue_activities + + @pytest.mark.django_db + def test_create_with_assignee_ids_tracks_and_subscribes(self, workspace, project, issue, author, assignee): + """Web app payload key: assignee activity is collected, assignee subscribed.""" + activities = self._run(issue, project, workspace, author, {"assignee_ids": [str(assignee.id)]}) + + assert any(activity.field == "assignees" for activity in activities) + assert IssueSubscriber.objects.filter(issue_id=issue.id, subscriber_id=assignee.id).exists() + + @pytest.mark.django_db + def test_create_with_assignees_tracks_and_subscribes(self, workspace, project, issue, author, assignee): + """External API payload key: must behave exactly like ``assignee_ids``.""" + activities = self._run(issue, project, workspace, author, {"assignees": [str(assignee.id)]}) + + assert any(activity.field == "assignees" for activity in activities) + assert IssueSubscriber.objects.filter(issue_id=issue.id, subscriber_id=assignee.id).exists() + + @pytest.mark.django_db + def test_create_without_assignees_only_creates_created_activity(self, workspace, project, issue, author): + """No assignee payload: no assignee activities, no subscriptions.""" + activities = self._run(issue, project, workspace, author, {"name": "Created via external API"}) + + assert activities == [] + assert not IssueSubscriber.objects.filter(issue_id=issue.id).exists() + assert IssueActivity.objects.filter(issue_id=issue.id, verb="created").exists()