Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[back] feat(notifications): u#2900 notify when the status of a story has changed #531

Merged
merged 1 commit into from
Oct 23, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat(notifications): u#2900 notify when the status of a story has cha…
…nged
bameda committed Oct 23, 2023
commit e4ee594a28779650f0d5442be590a15791b64a3c
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
@@ -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,
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
@@ -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]
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
@@ -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
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
@@ -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
)
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
@@ -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,
Original file line number Diff line number Diff line change
@@ -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,
142 changes: 114 additions & 28 deletions python/apps/taiga/tests/unit/taiga/stories/stories/test_services.py

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions python/apps/taiga/tests/unit/taiga/workflows/test_services.py
Original file line number Diff line number Diff line change
@@ -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,14 +432,17 @@ 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
)


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