diff --git a/.github/sql-fixtures/fixtures.sql b/.github/sql-fixtures/fixtures.sql index c923adf29..fce418b7a 100644 Binary files a/.github/sql-fixtures/fixtures.sql and b/.github/sql-fixtures/fixtures.sql differ diff --git a/.github/workflows/python-api-tests.yml b/.github/workflows/python-api-tests.yml index 007d34ebd..7795f6f39 100644 --- a/.github/workflows/python-api-tests.yml +++ b/.github/workflows/python-api-tests.yml @@ -16,6 +16,7 @@ on: env: TAIGA_SECRET_KEY: secret + TAIGA_DEBUG: true jobs: test: diff --git a/python/apps/taiga/src/taiga/comments/notifications.py b/python/apps/taiga/src/taiga/comments/notifications.py new file mode 100644 index 000000000..6ea79ecd6 --- /dev/null +++ b/python/apps/taiga/src/taiga/comments/notifications.py @@ -0,0 +1,16 @@ +# -*- 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 typing import Awaitable, Protocol + +from taiga.comments.models import Comment +from taiga.users.models import User + + +class NotificationOnCreateCallable(Protocol): + def __call__(self, comment: Comment, emitted_by: User) -> Awaitable[None]: + ... diff --git a/python/apps/taiga/src/taiga/comments/serializers.py b/python/apps/taiga/src/taiga/comments/serializers/__init__.py similarity index 100% rename from python/apps/taiga/src/taiga/comments/serializers.py rename to python/apps/taiga/src/taiga/comments/serializers/__init__.py diff --git a/python/apps/taiga/src/taiga/comments/serializers/nested.py b/python/apps/taiga/src/taiga/comments/serializers/nested.py new file mode 100644 index 000000000..0b6f8a066 --- /dev/null +++ b/python/apps/taiga/src/taiga/comments/serializers/nested.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 datetime import datetime + +from taiga.base.serializers import UUIDB64, BaseModel +from taiga.users.serializers.nested import UserNestedSerializer + + +class CommentNestedSerializer(BaseModel): + id: UUIDB64 + text: str + created_at: datetime + created_by: UserNestedSerializer | None + + class Config: + orm_mode = True diff --git a/python/apps/taiga/src/taiga/comments/services.py b/python/apps/taiga/src/taiga/comments/services.py index de7b756ab..1581cecda 100644 --- a/python/apps/taiga/src/taiga/comments/services.py +++ b/python/apps/taiga/src/taiga/comments/services.py @@ -14,6 +14,7 @@ from taiga.comments import repositories as comments_repositories from taiga.comments.events import EventOnCreateCallable, EventOnDeleteCallable, EventOnUpdateCallable from taiga.comments.models import Comment +from taiga.comments.notifications import NotificationOnCreateCallable from taiga.comments.repositories import CommentFilters, CommentOrderBy from taiga.stories.stories.models import Story from taiga.users.models import User @@ -28,6 +29,7 @@ async def create_comment( text: str, created_by: User, event_on_create: EventOnCreateCallable | None = None, + notification_on_create: NotificationOnCreateCallable | None = None, ) -> Comment: comment = await comments_repositories.create_comment( content_object=content_object, @@ -37,6 +39,8 @@ async def create_comment( if event_on_create: await event_on_create(comment=comment) + if notification_on_create: + await notification_on_create(comment=comment, emitted_by=created_by) return comment diff --git a/python/apps/taiga/src/taiga/notifications/migrations/0002_alter_notification_content.py b/python/apps/taiga/src/taiga/notifications/migrations/0002_alter_notification_content.py new file mode 100644 index 000000000..346176e75 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/migrations/0002_alter_notification_content.py @@ -0,0 +1,30 @@ +# -*- 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 + +# Generated by Django 4.2.3 on 2023-10-23 11:16 + +import django.contrib.postgres.fields.jsonb +import taiga.base.utils.json +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("notifications", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="notification", + name="content", + field=django.contrib.postgres.fields.jsonb.JSONField( + default=dict, + encoder=taiga.base.utils.json.JSONEncoder, + verbose_name="content", + ), + ), + ] diff --git a/python/apps/taiga/src/taiga/notifications/models.py b/python/apps/taiga/src/taiga/notifications/models.py index e0dbd1b6e..e27940e79 100644 --- a/python/apps/taiga/src/taiga/notifications/models.py +++ b/python/apps/taiga/src/taiga/notifications/models.py @@ -7,6 +7,7 @@ from taiga.base.db import models from taiga.base.db.mixins import CreatedMetaInfoMixin +from taiga.base.utils import json ####################################################################### # Base Notification @@ -37,6 +38,7 @@ class Notification(models.BaseModel, CreatedMetaInfoMixin): null=False, blank=False, default=dict, + encoder=json.JSONEncoder, verbose_name="content", ) diff --git a/python/apps/taiga/src/taiga/stories/comments/api.py b/python/apps/taiga/src/taiga/stories/comments/api.py index 88333b2fa..86482b24e 100644 --- a/python/apps/taiga/src/taiga/stories/comments/api.py +++ b/python/apps/taiga/src/taiga/stories/comments/api.py @@ -23,7 +23,7 @@ from taiga.exceptions.api.errors import ERROR_403, ERROR_404, ERROR_422 from taiga.permissions import HasPerm, IsNotDeleted, IsProjectAdmin, IsRelatedToTheUser from taiga.routers import routes -from taiga.stories.comments import events +from taiga.stories.comments import events, notifications from taiga.stories.stories.api import get_story_or_404 from taiga.stories.stories.models import Story @@ -60,9 +60,20 @@ async def create_story_comments( story = await get_story_or_404(project_id=project_id, ref=ref) await check_permissions(permissions=CREATE_STORY_COMMENT, user=request.user, obj=story) - event_on_create = partial(events.emit_event_when_story_comment_is_created, project=story.project) + event_on_create = partial( + events.emit_event_when_story_comment_is_created, + project=story.project, + ) + notification_on_create = partial( + notifications.notify_when_story_comment_is_created, + story=story, + ) return await comments_services.create_comment( - text=form.text, content_object=story, created_by=request.user, event_on_create=event_on_create + text=form.text, + content_object=story, + created_by=request.user, + event_on_create=event_on_create, + notification_on_create=notification_on_create, ) diff --git a/python/apps/taiga/src/taiga/stories/comments/notifications/__init__.py b/python/apps/taiga/src/taiga/stories/comments/notifications/__init__.py new file mode 100644 index 000000000..f7b42af84 --- /dev/null +++ b/python/apps/taiga/src/taiga/stories/comments/notifications/__init__.py @@ -0,0 +1,33 @@ +# -*- 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.comments.models import Comment +from taiga.notifications import services as notifications_services +from taiga.stories.comments.notifications.content import StoryCommentCreateNotificationContent +from taiga.stories.stories.models import Story +from taiga.users.models import User + +STORY_COMMENT_CREATE = "story_comment.create" + + +async def notify_when_story_comment_is_created(story: Story, comment: Comment, emitted_by: User) -> None: + 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=STORY_COMMENT_CREATE, + emitted_by=emitted_by, + notified_users=notified_users, + content=StoryCommentCreateNotificationContent( + project=story.project, + story=story, + commented_by=emitted_by, + comment=comment, + ), + ) diff --git a/python/apps/taiga/src/taiga/stories/comments/notifications/content.py b/python/apps/taiga/src/taiga/stories/comments/notifications/content.py new file mode 100644 index 000000000..a07b11740 --- /dev/null +++ b/python/apps/taiga/src/taiga/stories/comments/notifications/content.py @@ -0,0 +1,19 @@ +# -*- 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.comments.serializers.nested import CommentNestedSerializer +from taiga.projects.projects.serializers.nested import ProjectLinkNestedSerializer +from taiga.stories.stories.serializers.nested import StoryNestedSerializer +from taiga.users.serializers.nested import UserNestedSerializer + + +class StoryCommentCreateNotificationContent(BaseModel): + project: ProjectLinkNestedSerializer + story: StoryNestedSerializer + commented_by: UserNestedSerializer + comment: CommentNestedSerializer 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..277f5592d 100644 --- a/python/apps/taiga/src/taiga/stories/stories/services/__init__.py +++ b/python/apps/taiga/src/taiga/stories/stories/services/__init__.py @@ -110,6 +110,7 @@ async def get_story(project_id: UUID, ref: int) -> Story | None: return await stories_repositories.get_story( filters={"ref": ref, "project_id": project_id}, select_related=["project", "workspace", "workflow", "created_by"], + prefetch_related=["assignees"], ) @@ -362,4 +363,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/tests/unit/taiga/comments/test_services.py b/python/apps/taiga/tests/unit/taiga/comments/test_services.py index 7c148697a..88646734d 100644 --- a/python/apps/taiga/tests/unit/taiga/comments/test_services.py +++ b/python/apps/taiga/tests/unit/taiga/comments/test_services.py @@ -41,7 +41,7 @@ async def test_create_comment(): ) -async def test_create_comment_and_emit_event_on_create(): +async def test_create_comment_and_emit_event_on_creation(): project = f.build_project() story = f.build_story(project=project) fake_event_on_create = AsyncMock() @@ -68,6 +68,33 @@ async def test_create_comment_and_emit_event_on_create(): fake_event_on_create.assert_awaited_once_with(comment=comment) +async def test_create_comment_and_notify_on_creation(): + project = f.build_project() + story = f.build_story(project=project) + fake_notification_on_create = AsyncMock() + comment = f.build_comment() + + with ( + patch("taiga.comments.services.comments_repositories", autospec=True) as fake_comments_repositories, + patch("taiga.comments.models.Comment.project", new_callable=PropertyMock, return_value=project), + ): + fake_comments_repositories.create_comment.return_value = comment + + await services.create_comment( + content_object=story, + text=comment.text, + created_by=comment.created_by, + notification_on_create=fake_notification_on_create, + ) + + fake_comments_repositories.create_comment.assert_awaited_once_with( + content_object=story, + text=comment.text, + created_by=comment.created_by, + ) + fake_notification_on_create.assert_awaited_once_with(comment=comment, emitted_by=comment.created_by) + + ##################################################### # list_comments #####################################################