From 2981b30e3795ca8bba8dc233dc1dc059ef96df06 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 | 65 ++++++++++++++----- .../taiga/src/taiga/workflows/api/__init__.py | 1 + .../src/taiga/workflows/services/__init__.py | 7 +- 7 files changed, 120 insertions(+), 28 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..e329372b3 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,61 @@ 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=detailed_story.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 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( - filters={"workflow_id": workflow.id}, order_by=["order"], offset=0, limit=1 - ) + first_status = ( + await workflows_repositories.list_workflow_statuses( + filters={"workflow_id": workflow.id}, order_by=["order"], offset=0, limit=1 + ) + )[0] if not first_status: raise ex.WorkflowHasNotStatusesError("The provided workflow hasn't any statuses.") - output["status"] = first_status[0] + output.update( + workflow=workflow, + status=first_status, + order=await _calculate_next_order(status_id=first_status.id), + ) - # Calculate new order - output["order"] = await _calculate_next_order(status_id=first_status[0].id) + 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()) return output @@ -274,6 +291,7 @@ async def _calculate_offset( async def reorder_stories( + reordered_by: User, project: Project, workflow: Workflow, target_status_id: UUID, @@ -320,8 +338,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 +361,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 +393,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,