Skip to content

Commit

Permalink
feat(notifications): u#2900 notify when the status of a story has cha…
Browse files Browse the repository at this point in the history
…nged
  • Loading branch information
bameda committed Oct 23, 2023
1 parent f6cd0fc commit e4ee594
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 61 deletions.
17 changes: 7 additions & 10 deletions python/apps/taiga/src/taiga/stories/stories/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)


################################################
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
),
)
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions python/apps/taiga/src/taiga/stories/stories/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
71 changes: 54 additions & 17 deletions python/apps/taiga/src/taiga/stories/stories/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -274,6 +297,7 @@ async def _calculate_offset(


async def reorder_stories(
reordered_by: User,
project: Project,
workflow: Workflow,
target_status_id: UUID,
Expand Down Expand Up @@ -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)
Expand All @@ -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


Expand All @@ -362,4 +399,4 @@ async def delete_story(story: Story, deleted_by: AnyUser) -> bool:
)
return True

return False
return False
1 change: 1 addition & 0 deletions python/apps/taiga/src/taiga/workflows/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
7 changes: 6 additions & 1 deletion python/apps/taiga/src/taiga/workflows/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit e4ee594

Please sign in to comment.