From e4ee594a28779650f0d5442be590a15791b64a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Barrag=C3=A1n=20Merino?= Date: Thu, 19 Oct 2023 17:43:54 +0200 Subject: [PATCH] feat(notifications): u#2900 notify when the status of a story has changed --- .../src/taiga/stories/stories/api/__init__.py | 17 +-- .../stories/stories/notifications/__init__.py | 35 +++++ .../stories/stories/notifications/content.py | 21 +++ .../src/taiga/stories/stories/repositories.py | 2 + .../stories/stories/services/__init__.py | 71 ++++++--- .../taiga/src/taiga/workflows/api/__init__.py | 1 + .../src/taiga/workflows/services/__init__.py | 7 +- .../taiga/stories/stories/test_services.py | 11 +- .../taiga/stories/stories/test_services.py | 142 ++++++++++++++---- .../unit/taiga/workflows/test_services.py | 19 ++- 10 files changed, 265 insertions(+), 61 deletions(-) create mode 100644 python/apps/taiga/src/taiga/stories/stories/notifications/__init__.py create mode 100644 python/apps/taiga/src/taiga/stories/stories/notifications/content.py diff --git a/python/apps/taiga/src/taiga/stories/stories/api/__init__.py b/python/apps/taiga/src/taiga/stories/stories/api/__init__.py index cf5543c59..b6c1a33ee 100644 --- a/python/apps/taiga/src/taiga/stories/stories/api/__init__.py +++ b/python/apps/taiga/src/taiga/stories/stories/api/__init__.py @@ -11,7 +11,6 @@ from starlette.responses import Response from taiga.base.api import AuthRequest, PaginationQuery, responses, set_pagination from taiga.base.api.permissions import check_permissions -from taiga.base.utils.datetime import aware_utcnow from taiga.base.validators import B64UUID from taiga.exceptions import api as ex from taiga.exceptions.api.errors import ERROR_403, ERROR_404, ERROR_422 @@ -157,15 +156,12 @@ async def update_story( values = form.dict(exclude_unset=True) current_version = values.pop("version") - if "title" in values: - values["title_updated_by"] = request.user - values["title_updated_at"] = aware_utcnow() - - if "description" in values: - values["description_updated_by"] = request.user - values["description_updated_at"] = aware_utcnow() - - return await stories_services.update_story(story=story, current_version=current_version, values=values) + return await stories_services.update_story( + story=story, + updated_by=request.user, + current_version=current_version, + values=values, + ) ################################################ @@ -193,6 +189,7 @@ async def reorder_stories( await check_permissions(permissions=REORDER_STORIES, user=request.user, obj=workflow) return await stories_services.reorder_stories( + reordered_by=request.user, project=workflow.project, workflow=workflow, target_status_id=form.status, diff --git a/python/apps/taiga/src/taiga/stories/stories/notifications/__init__.py b/python/apps/taiga/src/taiga/stories/stories/notifications/__init__.py new file mode 100644 index 000000000..5c408cc76 --- /dev/null +++ b/python/apps/taiga/src/taiga/stories/stories/notifications/__init__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2023-present Kaleidos INC + +from taiga.notifications import services as notifications_services +from taiga.stories.stories.models import Story +from taiga.stories.stories.notifications.content import StoryStatusChangeNotificationContent +from taiga.users.models import User + +STORIES_STATUS_CHANGE = "stories.status_change" + + +async def notify_when_story_status_change(story: Story, status: str, emitted_by: User) -> None: + """ + Emit notification when a story status changes + """ + notified_users = {u async for u in story.assignees.all()} + if story.created_by: + notified_users.add(story.created_by) + notified_users.discard(emitted_by) + + await notifications_services.notify_users( + type=STORIES_STATUS_CHANGE, + emitted_by=emitted_by, + notified_users=notified_users, + content=StoryStatusChangeNotificationContent( + projects=story.project, + story=story, + changed_by=emitted_by, + status=status, + ), + ) diff --git a/python/apps/taiga/src/taiga/stories/stories/notifications/content.py b/python/apps/taiga/src/taiga/stories/stories/notifications/content.py new file mode 100644 index 000000000..322bb20e2 --- /dev/null +++ b/python/apps/taiga/src/taiga/stories/stories/notifications/content.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# Copyright (c) 2023-present Kaleidos INC + +from taiga.base.serializers import BaseModel +from taiga.projects.projects.serializers.nested import ProjectLinkNestedSerializer +from taiga.stories.stories.serializers.nested import StoryNestedSerializer +from taiga.users.serializers.nested import UserNestedSerializer + + +class StoryStatusChangeNotificationContent(BaseModel): + projects: ProjectLinkNestedSerializer + story: StoryNestedSerializer + changed_by: UserNestedSerializer + status: str + + class Config: + orm_mode = True diff --git a/python/apps/taiga/src/taiga/stories/stories/repositories.py b/python/apps/taiga/src/taiga/stories/stories/repositories.py index 4bc0bd597..69c117d33 100644 --- a/python/apps/taiga/src/taiga/stories/stories/repositories.py +++ b/python/apps/taiga/src/taiga/stories/stories/repositories.py @@ -235,6 +235,8 @@ def list_stories_to_reorder(filters: StoryFilters = {}) -> list[Story]: the order of the input references. """ qs = _apply_filters_to_queryset(qs=DEFAULT_QUERYSET, filters=filters) + qs = _apply_select_related_to_queryset(qs=qs, select_related=["status", "project", "created_by"]) + qs = _apply_prefetch_related_to_queryset(qs=qs, prefetch_related=["assignees"]) stories = {s.ref: s for s in qs} return [stories[ref] for ref in filters["refs"] if stories.get(ref) is not None] diff --git a/python/apps/taiga/src/taiga/stories/stories/services/__init__.py b/python/apps/taiga/src/taiga/stories/stories/services/__init__.py index 4aabcd5ac..760008326 100644 --- a/python/apps/taiga/src/taiga/stories/stories/services/__init__.py +++ b/python/apps/taiga/src/taiga/stories/stories/services/__init__.py @@ -11,8 +11,10 @@ from taiga.base.api import Pagination from taiga.base.repositories.neighbors import Neighbor +from taiga.base.utils.datetime import aware_utcnow from taiga.projects.projects.models import Project from taiga.stories.stories import events as stories_events +from taiga.stories.stories import notifications as stories_notifications from taiga.stories.stories import repositories as stories_repositories from taiga.stories.stories.models import Story from taiga.stories.stories.serializers import ReorderStoriesSerializer, StoryDetailSerializer, StorySummarySerializer @@ -151,10 +153,11 @@ async def get_story_detail( async def update_story( story: Story, current_version: int, + updated_by: User, values: dict[str, Any] = {}, ) -> StoryDetailSerializer: # Values to update - update_values = await _validate_and_process_values_to_update(story, values) + update_values = await _validate_and_process_values_to_update(story=story, updated_by=updated_by, values=values) # Old neighbors old_neighbors = None @@ -181,47 +184,67 @@ async def update_story( updates_attrs=[*update_values], ) + # Emit notifications + if "status" in update_values: + await stories_notifications.notify_when_story_status_change( + story=story, + status=update_values["status"].name, + emitted_by=updated_by, + ) + return detailed_story -async def _validate_and_process_values_to_update(story: Story, values: dict[str, Any]) -> dict[str, Any]: +async def _validate_and_process_values_to_update( + story: Story, updated_by: User, values: dict[str, Any] +) -> dict[str, Any]: output = values.copy() + if "title" in output: + output.update( + title_updated_by=updated_by, + title_updated_at=aware_utcnow(), + ) + + if "description" in output: + output.update( + description_updated_by=updated_by, + description_updated_at=aware_utcnow(), + ) + if status_id := output.pop("status", None): status = await workflows_repositories.get_workflow_status( filters={"workflow_id": story.workflow_id, "id": status_id} ) - if not status: raise ex.InvalidStatusError("The provided status is not valid.") - elif status.id != story.status_id: - output["status"] = status - # Calculate new order - output["order"] = await _calculate_next_order(status_id=status.id) + if status.id != story.status_id: + output.update(status=status, order=await _calculate_next_order(status_id=status.id)) elif workflow_slug := output.pop("workflow", None): workflow = await workflows_repositories.get_workflow( filters={"project_id": story.project_id, "slug": workflow_slug}, prefetch_related=["statuses"] ) - if not workflow: raise ex.InvalidWorkflowError("The provided workflow is not valid.") - elif workflow.slug != story.workflow.slug: - output["workflow"] = workflow + if workflow.slug != story.workflow.slug: # Set first status - first_status = await workflows_repositories.list_workflow_statuses( + first_status_list = await workflows_repositories.list_workflow_statuses( filters={"workflow_id": workflow.id}, order_by=["order"], offset=0, limit=1 ) - if not first_status: + if not first_status_list: raise ex.WorkflowHasNotStatusesError("The provided workflow hasn't any statuses.") + else: + first_status = first_status_list[0] - output["status"] = first_status[0] - - # Calculate new order - output["order"] = await _calculate_next_order(status_id=first_status[0].id) + output.update( + workflow=workflow, + status=first_status, + order=await _calculate_next_order(status_id=first_status.id), + ) return output @@ -274,6 +297,7 @@ async def _calculate_offset( async def reorder_stories( + reordered_by: User, project: Project, workflow: Workflow, target_status_id: UUID, @@ -320,8 +344,13 @@ async def reorder_stories( # update stories stories_to_update_tmp = {s.ref: s for s in stories_to_reorder} stories_to_update = [] + stories_with_changed_status = [] for i, ref in enumerate(stories_refs): story = stories_to_update_tmp[ref] + + if story.status != target_status: + stories_with_changed_status.append(story) + story.status = target_status story.order = pre_order + (offset * (i + 1)) stories_to_update.append(story) @@ -338,6 +367,14 @@ async def reorder_stories( # event await stories_events.emit_when_stories_are_reordered(project=project, reorder=reorder_story_serializer) + # notifications + for story in stories_with_changed_status: + await stories_notifications.notify_when_story_status_change( + story=story, + status=story.status.name, + emitted_by=reordered_by, + ) + return reorder_story_serializer @@ -362,4 +399,4 @@ async def delete_story(story: Story, deleted_by: AnyUser) -> bool: ) return True - return False \ No newline at end of file + return False diff --git a/python/apps/taiga/src/taiga/workflows/api/__init__.py b/python/apps/taiga/src/taiga/workflows/api/__init__.py index f76ba7f46..31b1e6bc1 100644 --- a/python/apps/taiga/src/taiga/workflows/api/__init__.py +++ b/python/apps/taiga/src/taiga/workflows/api/__init__.py @@ -287,6 +287,7 @@ async def delete_workflow_status( await check_permissions(permissions=DELETE_WORKFLOW_STATUS, user=request.user, obj=workflow_status) await workflows_services.delete_workflow_status( + deleted_by=request.user, workflow_status=workflow_status, target_status_id=query_params.move_to, # type: ignore ) diff --git a/python/apps/taiga/src/taiga/workflows/services/__init__.py b/python/apps/taiga/src/taiga/workflows/services/__init__.py index cbeac0bf6..ba92a8b31 100644 --- a/python/apps/taiga/src/taiga/workflows/services/__init__.py +++ b/python/apps/taiga/src/taiga/workflows/services/__init__.py @@ -15,6 +15,7 @@ from taiga.projects.projects.models import Project from taiga.stories.stories import repositories as stories_repositories from taiga.stories.stories import services as stories_services +from taiga.users.models import User from taiga.workflows import events as workflows_events from taiga.workflows import repositories as workflows_repositories from taiga.workflows.models import Workflow, WorkflowStatus @@ -296,11 +297,14 @@ async def reorder_workflow_statuses( ########################################################## -async def delete_workflow_status(workflow_status: WorkflowStatus, target_status_id: UUID | None) -> bool: +async def delete_workflow_status( + workflow_status: WorkflowStatus, deleted_by: User, target_status_id: UUID | None +) -> bool: """ This method deletes a workflow status, providing the option to first migrating its stories to another workflow status of the same workflow. + :param deleted_by: the user who is deleting the workflow status :param workflow_status: the workflow status to delete :param target_status_id: the workflow status's id to which move the stories from the status being deleted - if not received, all the workflow status and its contained stories will be deleted @@ -329,6 +333,7 @@ async def delete_workflow_status(workflow_status: WorkflowStatus, target_status_ if stories_to_move: await stories_services.reorder_stories( + reordered_by=deleted_by, project=workflow_status.project, workflow=workflow_status.workflow, target_status_id=target_status_id, diff --git a/python/apps/taiga/tests/integration/taiga/stories/stories/test_services.py b/python/apps/taiga/tests/integration/taiga/stories/stories/test_services.py index 2e4733652..65496c02b 100644 --- a/python/apps/taiga/tests/integration/taiga/stories/stories/test_services.py +++ b/python/apps/taiga/tests/integration/taiga/stories/stories/test_services.py @@ -37,6 +37,7 @@ async def test_not_reorder_in_empty_status() -> None: # | story3 | | await services.reorder_stories( + reordered_by=project.created_by, project=project, workflow=workflow, target_status_id=status_2.id, @@ -72,7 +73,11 @@ async def test_not_reorder_in_populated_status() -> None: # | story2 | | await services.reorder_stories( - project=project, workflow=workflow, target_status_id=status_2.id, stories_refs=[story2.ref] + reordered_by=project.created_by, + project=project, + workflow=workflow, + target_status_id=status_2.id, + stories_refs=[story2.ref], ) # Now should be # | status_1 | status_2 | @@ -103,6 +108,7 @@ async def test_after_in_the_end() -> None: # | story2 | | await services.reorder_stories( + reordered_by=project.created_by, project=project, workflow=workflow, target_status_id=status_2.id, @@ -138,6 +144,7 @@ async def test_after_in_the_middle() -> None: # | | story3 | await services.reorder_stories( + reordered_by=project.created_by, project=project, workflow=workflow, target_status_id=status_2.id, @@ -175,6 +182,7 @@ async def test_before_in_the_beginning() -> None: # | | story3 | await services.reorder_stories( + reordered_by=project.created_by, project=project, workflow=workflow, target_status_id=status_2.id, @@ -212,6 +220,7 @@ async def test_before_in_the_middle() -> None: # | | story3 | await services.reorder_stories( + reordered_by=project.created_by, project=project, workflow=workflow, target_status_id=status_2.id, diff --git a/python/apps/taiga/tests/unit/taiga/stories/stories/test_services.py b/python/apps/taiga/tests/unit/taiga/stories/stories/test_services.py index 715599960..3e4ad3806 100644 --- a/python/apps/taiga/tests/unit/taiga/stories/stories/test_services.py +++ b/python/apps/taiga/tests/unit/taiga/stories/stories/test_services.py @@ -199,6 +199,7 @@ async def test_get_story_detail_no_neighbors(): async def test_update_story_ok(): + user = f.build_user() story = f.build_story() values = {"title": "new title", "description": "new description"} detailed_story = { @@ -215,12 +216,14 @@ async def test_update_story_ok(): ) as fake_validate_and_process, patch("taiga.stories.stories.services.get_story_detail", autospec=True) as fake_get_story_detail, patch("taiga.stories.stories.services.stories_events", autospec=True) as fake_stories_events, + patch("taiga.stories.stories.services.stories_notifications", autospec=True) as fake_notifications, ): fake_validate_and_process.return_value = values fake_stories_repo.update_story.return_value = True fake_get_story_detail.return_value = detailed_story updated_story = await services.update_story( + updated_by=user, story=story, current_version=story.version, values=values, @@ -229,22 +232,30 @@ async def test_update_story_ok(): fake_validate_and_process.assert_awaited_once_with( story=story, values=values, + updated_by=user, ) fake_stories_repo.update_story.assert_awaited_once_with( id=story.id, current_version=story.version, values=values, ) - fake_get_story_detail.assert_awaited_once_with(project_id=story.project_id, ref=story.ref, neighbors=None) + fake_get_story_detail.assert_awaited_once_with( + project_id=story.project_id, + ref=story.ref, + neighbors=None, + ) fake_stories_events.emit_event_when_story_is_updated.assert_awaited_once_with( project=story.project, story=updated_story, updates_attrs=[*values], ) + fake_notifications.notify_when_story_status_change.assert_not_awaited() + assert updated_story == detailed_story async def test_update_story_workflow_ok(): + user = f.build_user() project = f.build_project() old_workflow = f.build_workflow(project=project) workflow_status1 = f.build_workflow_status(workflow=old_workflow) @@ -279,6 +290,7 @@ async def test_update_story_workflow_ok(): ) as fake_validate_and_process, patch("taiga.stories.stories.services.get_story_detail", autospec=True) as fake_get_story_detail, patch("taiga.stories.stories.services.stories_events", autospec=True) as fake_stories_events, + patch("taiga.stories.stories.services.stories_notifications", autospec=True) as fake_notifications, ): fake_validate_and_process.return_value = values fake_stories_repo.list_story_neighbors.return_value = old_neighbors @@ -286,14 +298,17 @@ async def test_update_story_workflow_ok(): fake_get_story_detail.return_value = detailed_story updated_story = await services.update_story( + updated_by=user, story=story2, current_version=story2.version, values=values, ) + assert updated_story == detailed_story fake_validate_and_process.assert_awaited_once_with( story=story2, values=values, + updated_by=user, ) fake_stories_repo.update_story.assert_awaited_once_with( id=story2.id, @@ -308,10 +323,12 @@ async def test_update_story_workflow_ok(): story=updated_story, updates_attrs=[*values], ) - assert updated_story == detailed_story + + fake_notifications.notify_when_story_status_change.assert_awaited_once() async def test_update_story_error_wrong_version(): + user = f.build_user() story = f.build_story() values = {"title": "new title"} @@ -322,15 +339,23 @@ async def test_update_story_error_wrong_version(): ) as fake_validate_and_process, patch("taiga.stories.stories.services.get_story_detail", autospec=True) as fake_get_story_detail, patch("taiga.stories.stories.services.stories_events", autospec=True) as fake_stories_events, + patch("taiga.stories.stories.services.stories_notifications", autospec=True) as fake_notifications, ): fake_validate_and_process.return_value = values fake_stories_repo.update_story.return_value = False with pytest.raises(ex.UpdatingStoryWithWrongVersionError): - await services.update_story(story=story, current_version=story.version, values=values) + await services.update_story( + updated_by=user, + story=story, + current_version=story.version, + values=values, + ) + fake_validate_and_process.assert_awaited_once_with( story=story, values=values, + updated_by=user, ) fake_stories_repo.update_story.assert_awaited_once_with( id=story.id, @@ -339,6 +364,7 @@ async def test_update_story_error_wrong_version(): ) fake_get_story_detail.assert_not_awaited() fake_stories_events.emit_event_when_story_is_updated.assert_not_awaited() + fake_notifications.notify_when_story_status_change.assert_not_awaited() ####################################################### @@ -347,6 +373,7 @@ async def test_update_story_error_wrong_version(): async def test_validate_and_process_values_to_update_ok_without_status(): + user = f.build_user() story = f.build_story() values = {"title": "new title", "description": "new description"} @@ -354,15 +381,23 @@ async def test_validate_and_process_values_to_update_ok_without_status(): patch("taiga.stories.stories.services.stories_repositories", autospec=True) as fake_stories_repo, patch("taiga.stories.stories.services.workflows_repositories", autospec=True) as fake_workflows_repo, ): - valid_values = await services._validate_and_process_values_to_update(story=story, values=values) + valid_values = await services._validate_and_process_values_to_update( + story=story, values=values, updated_by=user + ) fake_workflows_repo.get_workflow_status.assert_not_awaited() fake_stories_repo.list_stories.assert_not_awaited() - assert valid_values == values + assert valid_values["title"] == values["title"] + assert "title_updated_at" in valid_values + assert "title_updated_by" in valid_values + assert valid_values["description"] == values["description"] + assert "description_updated_at" in valid_values + assert "description_updated_by" in valid_values async def test_validate_and_process_values_to_update_ok_with_status_empty(): + user = f.build_user() story = f.build_story() status = f.build_workflow_status() values = {"title": "new title", "description": "new description", "status": status.id} @@ -374,7 +409,9 @@ async def test_validate_and_process_values_to_update_ok_with_status_empty(): fake_workflows_repo.get_workflow_status.return_value = status fake_stories_repo.list_stories.return_value = [] - valid_values = await services._validate_and_process_values_to_update(story=story, values=values) + valid_values = await services._validate_and_process_values_to_update( + story=story, values=values, updated_by=user + ) fake_workflows_repo.get_workflow_status.assert_awaited_once_with( filters={"workflow_id": story.workflow_id, "id": values["status"]}, @@ -387,11 +424,17 @@ async def test_validate_and_process_values_to_update_ok_with_status_empty(): ) assert valid_values["title"] == values["title"] + assert "title_updated_at" in valid_values + assert "title_updated_by" in valid_values + assert valid_values["description"] == values["description"] + assert "description_updated_at" in valid_values + assert "description_updated_by" in valid_values assert valid_values["status"] == status assert valid_values["order"] == services.DEFAULT_ORDER_OFFSET async def test_validate_and_process_values_to_update_ok_with_status_not_empty(): + user = f.build_user() story = f.build_story() status = f.build_workflow_status() story2 = f.build_story(status=status, order=42) @@ -404,7 +447,9 @@ async def test_validate_and_process_values_to_update_ok_with_status_not_empty(): fake_workflows_repo.get_workflow_status.return_value = status fake_stories_repo.list_stories.return_value = [story2] - valid_values = await services._validate_and_process_values_to_update(story=story, values=values) + valid_values = await services._validate_and_process_values_to_update( + story=story, values=values, updated_by=user + ) fake_workflows_repo.get_workflow_status.assert_awaited_once_with( filters={"workflow_id": story.workflow_id, "id": values["status"]}, @@ -417,11 +462,17 @@ async def test_validate_and_process_values_to_update_ok_with_status_not_empty(): ) assert valid_values["title"] == values["title"] + assert "title_updated_at" in valid_values + assert "title_updated_by" in valid_values + assert valid_values["description"] == values["description"] + assert "description_updated_at" in valid_values + assert "description_updated_by" in valid_values assert valid_values["status"] == status assert valid_values["order"] == services.DEFAULT_ORDER_OFFSET + story2.order async def test_validate_and_process_values_to_update_ok_with_same_status(): + user = f.build_user() status = f.build_workflow_status() story = f.build_story(status=status) values = {"title": "new title", "description": "new description", "status": status.id} @@ -432,7 +483,9 @@ async def test_validate_and_process_values_to_update_ok_with_same_status(): ): fake_workflows_repo.get_workflow_status.return_value = status - valid_values = await services._validate_and_process_values_to_update(story=story, values=values) + valid_values = await services._validate_and_process_values_to_update( + story=story, values=values, updated_by=user + ) fake_workflows_repo.get_workflow_status.assert_awaited_once_with( filters={"workflow_id": story.workflow_id, "id": values["status"]}, @@ -440,11 +493,17 @@ async def test_validate_and_process_values_to_update_ok_with_same_status(): fake_stories_repo.list_stories.assert_not_awaited() assert valid_values["title"] == values["title"] + assert "title_updated_at" in valid_values + assert "title_updated_by" in valid_values + assert valid_values["description"] == values["description"] + assert "description_updated_at" in valid_values + assert "description_updated_by" in valid_values assert "status" not in valid_values assert "order" not in valid_values async def test_validate_and_process_values_to_update_error_wrong_status(): + user = f.build_user() story = f.build_story() values = {"title": "new title", "description": "new description", "status": "wrong_status"} @@ -455,7 +514,7 @@ async def test_validate_and_process_values_to_update_error_wrong_status(): fake_workflows_repo.get_workflow_status.return_value = None with pytest.raises(ex.InvalidStatusError): - await services._validate_and_process_values_to_update(story=story, values=values) + await services._validate_and_process_values_to_update(story=story, values=values, updated_by=user) fake_workflows_repo.get_workflow_status.assert_awaited_once_with( filters={"workflow_id": story.workflow_id, "id": "wrong_status"}, @@ -464,6 +523,7 @@ async def test_validate_and_process_values_to_update_error_wrong_status(): async def test_validate_and_process_values_to_update_ok_with_workflow(): + user = f.build_user() project = f.build_project() workflow1 = f.build_workflow(project=project) status1 = f.build_workflow_status(workflow=workflow1) @@ -482,7 +542,9 @@ async def test_validate_and_process_values_to_update_ok_with_workflow(): fake_workflows_repo.list_workflow_statuses.return_value = [status2] fake_stories_repo.list_stories.return_value = [story2, status3] - valid_values = await services._validate_and_process_values_to_update(story=story1, values=values) + valid_values = await services._validate_and_process_values_to_update( + story=story1, values=values, updated_by=user + ) fake_workflows_repo.get_workflow.assert_awaited_once_with( filters={"project_id": story1.project_id, "slug": workflow2.slug}, prefetch_related=["statuses"] @@ -502,6 +564,7 @@ async def test_validate_and_process_values_to_update_ok_with_workflow(): async def test_validate_and_process_values_to_update_error_wrong_workflow(): + user = f.build_user() story = f.build_story() values = {"version": story.version, "workflow": "wrong_workflow"} @@ -512,7 +575,7 @@ async def test_validate_and_process_values_to_update_error_wrong_workflow(): fake_workflows_repo.get_workflow.return_value = None with pytest.raises(ex.InvalidWorkflowError): - await services._validate_and_process_values_to_update(story=story, values=values) + await services._validate_and_process_values_to_update(story=story, values=values, updated_by=user) fake_workflows_repo.get_workflow.assert_awaited_once_with( filters={"project_id": story.project_id, "slug": "wrong_workflow"}, prefetch_related=["statuses"] @@ -521,6 +584,7 @@ async def test_validate_and_process_values_to_update_error_wrong_workflow(): async def test_validate_and_process_values_to_update_error_workflow_without_statuses(): + user = f.build_user() project = f.build_project() workflow1 = f.build_workflow(project=project) status1 = f.build_workflow_status(workflow=workflow1) @@ -536,7 +600,7 @@ async def test_validate_and_process_values_to_update_error_workflow_without_stat fake_workflows_repo.list_workflow_statuses.return_value = [] with pytest.raises(ex.WorkflowHasNotStatusesError): - await services._validate_and_process_values_to_update(story=story, values=values) + await services._validate_and_process_values_to_update(story=story, values=values, updated_by=user) fake_workflows_repo.get_workflow.assert_awaited_once_with( filters={"project_id": story.project_id, "slug": workflow2.slug}, prefetch_related=["statuses"] @@ -609,24 +673,30 @@ async def test_calculate_offset() -> None: async def test_reorder_stories_ok(): + user = f.build_user() + project = f.build_project() + workflow = f.build_workflow() + target_status = f.build_workflow_status() + reorder_story = f.build_story(ref=3) + s1 = f.build_story(ref=13) + s2 = f.build_story(ref=54) + s3 = f.build_story(ref=2) + with ( patch("taiga.stories.stories.services.workflows_repositories", autospec=True) as fake_workflows_repo, patch("taiga.stories.stories.services.stories_repositories", autospec=True) as fake_stories_repo, patch("taiga.stories.stories.services.stories_events", autospec=True) as fake_stories_events, + patch("taiga.stories.stories.services.stories_notifications", autospec=True) as fake_notifications, ): - target_status = f.build_workflow_status() fake_workflows_repo.get_workflow_status.return_value = target_status - reorder_story = f.build_story(ref=3) fake_stories_repo.get_story.return_value = reorder_story - s1 = f.build_story(ref=13) - s2 = f.build_story(ref=54) - s3 = f.build_story(ref=2) fake_stories_repo.list_stories_to_reorder.return_value = [s1, s2, s3] await services.reorder_stories( - project=f.build_project(), + reordered_by=user, + project=project, target_status_id=target_status.id, - workflow=f.build_workflow(), + workflow=workflow, stories_refs=[13, 54, 2], reorder={"place": "after", "ref": reorder_story.ref}, ) @@ -635,9 +705,14 @@ async def test_reorder_stories_ok(): objs_to_update=[s1, s2, s3], fields_to_update=["status", "order"] ) fake_stories_events.emit_when_stories_are_reordered.assert_awaited_once() + assert fake_notifications.notify_when_story_status_change.await_count == 3 async def test_reorder_story_workflowstatus_does_not_exist(): + user = f.build_user() + project = f.build_project() + workflow = f.build_workflow() + with ( patch("taiga.stories.stories.services.workflows_repositories", autospec=True) as fake_workflows_repo, pytest.raises(ex.InvalidStatusError), @@ -645,51 +720,62 @@ async def test_reorder_story_workflowstatus_does_not_exist(): fake_workflows_repo.get_workflow_status.return_value = None await services.reorder_stories( - project=f.build_project(), + reordered_by=user, + project=project, target_status_id="non-existing", - workflow=f.build_workflow(), + workflow=workflow, stories_refs=[13, 54, 2], reorder={"place": "after", "ref": 3}, ) async def test_reorder_story_story_ref_does_not_exist(): + user = f.build_user() + project = f.build_project() + workflow = f.build_workflow() + target_status = f.build_workflow_status() + with ( patch("taiga.stories.stories.services.workflows_repositories", autospec=True) as fake_workflows_repo, patch("taiga.stories.stories.services.stories_repositories", autospec=True) as fake_stories_repo, pytest.raises(ex.InvalidStoryRefError), ): - target_status = f.build_workflow_status() fake_workflows_repo.get_workflow_status.return_value = target_status fake_stories_repo.get_story.return_value = None await services.reorder_stories( - project=f.build_project(), + reordered_by=user, + project=project, target_status_id=target_status.id, - workflow=f.build_workflow(), + workflow=workflow, stories_refs=[13, 54, 2], reorder={"place": "after", "ref": 3}, ) async def test_reorder_story_not_all_stories_exist(): + user = f.build_user() + project = f.build_project() + workflow = f.build_workflow() + target_status = f.build_workflow_status() + reorder_story = f.build_story(ref=3) + with ( patch("taiga.stories.stories.services.workflows_repositories", autospec=True) as fake_workflows_repo, patch("taiga.stories.stories.services.stories_repositories", autospec=True) as fake_stories_repo, pytest.raises(ex.InvalidStoryRefError), ): - target_status = f.build_workflow_status() fake_workflows_repo.get_workflow_status.return_value = target_status - reorder_story = f.build_story(ref=3) fake_stories_repo.get_story.return_value = reorder_story fake_stories_repo.list_stories.return_value = [f.build_story] await services.reorder_stories( - project=f.build_project(), + reordered_by=user, + project=project, target_status_id=target_status.id, - workflow=f.build_workflow(), + workflow=workflow, stories_refs=[13, 54, 2], reorder={"place": "after", "ref": reorder_story.ref}, ) diff --git a/python/apps/taiga/tests/unit/taiga/workflows/test_services.py b/python/apps/taiga/tests/unit/taiga/workflows/test_services.py index bac480d79..19b3da1f6 100644 --- a/python/apps/taiga/tests/unit/taiga/workflows/test_services.py +++ b/python/apps/taiga/tests/unit/taiga/workflows/test_services.py @@ -345,6 +345,7 @@ async def test_reorder_any_workflow_status_does_not_exist(): async def test_delete_workflow_status_moving_stories_ok(): + user = f.create_user() workflow = f.build_workflow() workflow_status1 = f.build_workflow_status(workflow=workflow) workflow_status2 = f.build_workflow_status(workflow=workflow) @@ -362,7 +363,9 @@ async def test_delete_workflow_status_moving_stories_ok(): fake_stories_repo.list_stories.return_value = workflow_status1_stories fake_stories_services.reorder_stories.return_value = None - await services.delete_workflow_status(workflow_status=workflow_status1, target_status_id=workflow_status2.id) + await services.delete_workflow_status( + workflow_status=workflow_status1, target_status_id=workflow_status2.id, deleted_by=user + ) fake_get_workflow_status.assert_awaited_once_with( project_id=workflow.project.id, workflow_slug=workflow.slug, id=workflow_status2.id @@ -374,6 +377,7 @@ async def test_delete_workflow_status_moving_stories_ok(): order_by=["order"], ) fake_stories_services.reorder_stories.assert_awaited_once_with( + reordered_by=user, project=workflow_status1.project, workflow=workflow, target_status_id=workflow_status2.id, @@ -388,6 +392,7 @@ async def test_delete_workflow_status_moving_stories_ok(): async def test_delete_workflow_status_deleting_stories_ok(): + user = f.create_user() workflow = f.build_workflow() workflow_status1 = f.build_workflow_status(workflow=workflow) workflow_status1_stories = [f.build_story(status=workflow_status1, workflow=workflow)] @@ -404,7 +409,7 @@ async def test_delete_workflow_status_deleting_stories_ok(): fake_stories_repo.list_stories.return_value = workflow_status1_stories fake_stories_services.reorder_stories.return_value = None - await services.delete_workflow_status(workflow_status=workflow_status1, target_status_id=None) + await services.delete_workflow_status(workflow_status=workflow_status1, target_status_id=None, deleted_by=user) fake_get_workflow_status.assert_not_awaited() fake_stories_repo.list_stories.assert_not_awaited() @@ -416,6 +421,7 @@ async def test_delete_workflow_status_deleting_stories_ok(): async def test_delete_workflow_status_wrong_target_status_ex(): + user = f.create_user() workflow = f.build_workflow() workflow_status1 = f.build_workflow_status(workflow=workflow) workflow_status2 = f.build_workflow_status(workflow=workflow) @@ -426,7 +432,9 @@ async def test_delete_workflow_status_wrong_target_status_ex(): ): fake_get_workflow_status.return_value = None - await services.delete_workflow_status(workflow_status=workflow_status1, target_status_id=workflow_status2.id) + await services.delete_workflow_status( + workflow_status=workflow_status1, target_status_id=workflow_status2.id, deleted_by=user + ) fake_get_workflow_status.assert_awaited_once_with( project_id=workflow.project.id, workflow_slug=workflow.slug, id=workflow_status2.id @@ -434,6 +442,7 @@ async def test_delete_workflow_status_wrong_target_status_ex(): async def test_delete_workflow_status_same_target_status_ex(): + user = f.create_user() workflow = f.build_workflow() workflow_status1 = f.build_workflow_status(workflow=workflow) @@ -443,7 +452,9 @@ async def test_delete_workflow_status_same_target_status_ex(): ): fake_get_workflow_status.return_value = workflow_status1 - await services.delete_workflow_status(workflow_status=workflow_status1, target_status_id=workflow_status1.id) + await services.delete_workflow_status( + workflow_status=workflow_status1, target_status_id=workflow_status1.id, deleted_by=user + ) fake_get_workflow_status.assert_awaited_once_with( project_id=workflow.project.id, workflow_slug=workflow.slug, id=workflow_status1.id