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
Show file tree
Hide file tree
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
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
Loading