diff --git a/.github/sql-fixtures/fixtures.sql b/.github/sql-fixtures/fixtures.sql index 4450468c4..c923adf29 100644 Binary files a/.github/sql-fixtures/fixtures.sql and b/.github/sql-fixtures/fixtures.sql differ diff --git a/python/apps/taiga/src/taiga/attachments/repositories.py b/python/apps/taiga/src/taiga/attachments/repositories.py index e21c09c00..ea8d847af 100644 --- a/python/apps/taiga/src/taiga/attachments/repositories.py +++ b/python/apps/taiga/src/taiga/attachments/repositories.py @@ -69,7 +69,7 @@ async def _apply_prefetch_related_to_queryset( ########################################################## -# create comment +# create attachment ########################################################## diff --git a/python/apps/taiga/src/taiga/base/db/commands.py b/python/apps/taiga/src/taiga/base/db/commands.py index a88eaab9a..cb847517b 100644 --- a/python/apps/taiga/src/taiga/base/db/commands.py +++ b/python/apps/taiga/src/taiga/base/db/commands.py @@ -15,7 +15,6 @@ from django.conf import settings from taiga.base.django.commands import call_django_command from taiga.base.utils import pprint -from taiga.base.utils.commands import unwrap_typer_param cli = typer.Typer( name="The Taiga DB Manager", @@ -75,7 +74,6 @@ def init_migrations( @cli.command(help="Creates new migration(s) for apps.") -@unwrap_typer_param def make_migrations( name: str = typer.Option("", "--name", "-n", help="Use this name for migration file(s)."), dry_run: bool = typer.Option( diff --git a/python/apps/taiga/src/taiga/base/django/settings.py b/python/apps/taiga/src/taiga/base/django/settings.py index 3fb45be47..fd2fd525f 100644 --- a/python/apps/taiga/src/taiga/base/django/settings.py +++ b/python/apps/taiga/src/taiga/base/django/settings.py @@ -41,6 +41,7 @@ "taiga.commons.storage", "taiga.emails", "taiga.mediafiles", + "taiga.notifications", "taiga.projects.invitations", "taiga.projects.memberships", "taiga.projects.projects", diff --git a/python/apps/taiga/src/taiga/base/serializers/__init__.py b/python/apps/taiga/src/taiga/base/serializers/__init__.py index d602df583..0667b635a 100644 --- a/python/apps/taiga/src/taiga/base/serializers/__init__.py +++ b/python/apps/taiga/src/taiga/base/serializers/__init__.py @@ -7,7 +7,7 @@ from humps.main import camelize from pydantic import BaseModel as _BaseModel -from taiga.base.serializers.fields import UUIDB64 # noqa +from taiga.base.serializers.fields import UUIDB64, CamelizeDict, FileField # noqa class BaseModel(_BaseModel): diff --git a/python/apps/taiga/src/taiga/base/serializers/fields.py b/python/apps/taiga/src/taiga/base/serializers/fields.py index 596ca9597..c29ab969e 100644 --- a/python/apps/taiga/src/taiga/base/serializers/fields.py +++ b/python/apps/taiga/src/taiga/base/serializers/fields.py @@ -5,9 +5,10 @@ # # Copyright (c) 2023-present Kaleidos INC -from typing import Any, Callable, Generator +from typing import Any, Callable, Generator, TypeVar from uuid import UUID +from humps.main import camelize from pydantic import AnyHttpUrl, AnyUrl, BaseConfig from pydantic.fields import ModelField from taiga.base.utils.uuid import encode_uuid_to_b64str @@ -18,7 +19,9 @@ class UUIDB64(UUID): @classmethod def __modify_schema__(cls, field_schema: dict[str, Any]) -> None: - field_schema["example"] = "6JgsbGyoEe2VExhWgGrI2w" + field_schema.update( + example="6JgsbGyoEe2VExhWgGrI2w", + ) @classmethod def __get_validators__(cls) -> CallableGenerator: @@ -33,3 +36,26 @@ class FileField(AnyHttpUrl): @classmethod def validate(cls, value: Any, field: ModelField, config: BaseConfig) -> AnyUrl: return value.url + + +_Key = TypeVar("_Key") +_Val = TypeVar("_Val") + + +class CamelizeDict(str): + def __modify_schema__(cls, field_schema: dict[str, Any]) -> None: + field_schema.update( + type="object", + example={}, + ) + + @classmethod + def __get_validators__(cls) -> CallableGenerator: + yield cls.validate + + @classmethod + def validate(cls, value: dict[_Key, _Val]) -> dict[_Key, _Val]: + return camelize(value) + + def __repr__(self) -> str: + return "CamelizeDict" diff --git a/python/apps/taiga/src/taiga/base/utils/commands.py b/python/apps/taiga/src/taiga/base/utils/commands.py index 2ccbd7e0e..d88331e44 100644 --- a/python/apps/taiga/src/taiga/base/utils/commands.py +++ b/python/apps/taiga/src/taiga/base/utils/commands.py @@ -5,13 +5,10 @@ # # Copyright (c) 2023-present Kaleidos INC -import functools import os from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, Generator - -import typer +from typing import Generator @contextmanager @@ -31,30 +28,3 @@ def set_working_directory(path: Path) -> Generator[None, None, None]: yield finally: os.chdir(origin) - - -def unwrap_typer_param(f: Callable[..., Any]) -> Callable[..., Any]: - """ - Unwraps the default values from typer.Argument or typer.Option to allow function to be called normally. - See: https://github.com/tiangolo/typer/issues/279 and https://gitlab.com/boratko/typer-utils - """ - if f.__defaults__ is None: - return f - else: - patched_defaults = [] - actual_default_observed = False - for i, value in enumerate(f.__defaults__): - default_value = value.default if isinstance(value, typer.models.ParameterInfo) else value - if default_value != ...: - actual_default_observed = True - patched_defaults.append(default_value) - elif actual_default_observed: - raise SyntaxError("non-default argument follows default argument") - f.__defaults__ = tuple(patched_defaults) - - @functools.wraps(f) - def wrapper(*args: Any, **kwargs: Any) -> Callable[..., Any]: - f.__defaults__ = tuple(patched_defaults) - return f(*args, **kwargs) - - return wrapper diff --git a/python/apps/taiga/src/taiga/base/validators/fields/i18n.py b/python/apps/taiga/src/taiga/base/validators/fields/i18n.py index 53aaa3c85..e58527b7e 100644 --- a/python/apps/taiga/src/taiga/base/validators/fields/i18n.py +++ b/python/apps/taiga/src/taiga/base/validators/fields/i18n.py @@ -16,8 +16,10 @@ class LanguageCode(str): @classmethod def __modify_schema__(cls: Type["LanguageCode"], field_schema: dict[str, Any]) -> None: - field_schema["example"] = settings.LANG - field_schema["enum"] = i18n.available_languages + field_schema.update( + example=settings.LANG, + enum=i18n.available_languages, + ) @classmethod def __get_validators__(cls: Type["LanguageCode"]) -> CallableGenerator: diff --git a/python/apps/taiga/src/taiga/base/validators/fields/uuid.py b/python/apps/taiga/src/taiga/base/validators/fields/uuid.py index e8bee6309..5929ee813 100644 --- a/python/apps/taiga/src/taiga/base/validators/fields/uuid.py +++ b/python/apps/taiga/src/taiga/base/validators/fields/uuid.py @@ -16,8 +16,10 @@ class B64UUID(UUID): @classmethod def __modify_schema__(cls: Type["B64UUID"], field_schema: dict[str, Any]) -> None: - field_schema["example"] = "6JgsbGyoEe2VExhWgGrI2w" - field_schema["format"] = None + field_schema.update( + example="6JgsbGyoEe2VExhWgGrI2w", + format=None, + ) @classmethod def __get_validators__(cls: Type["B64UUID"]) -> CallableGenerator: diff --git a/python/apps/taiga/src/taiga/notifications/__init__.py b/python/apps/taiga/src/taiga/notifications/__init__.py new file mode 100644 index 000000000..87d9a5256 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/__init__.py @@ -0,0 +1,6 @@ +# -*- 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 diff --git a/python/apps/taiga/src/taiga/notifications/admin.py b/python/apps/taiga/src/taiga/notifications/admin.py new file mode 100644 index 000000000..89fab12c8 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/admin.py @@ -0,0 +1,23 @@ +# -*- 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 Any + +from taiga.base.db import admin +from taiga.base.db.admin.http import HttpRequest +from taiga.notifications.models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin[Notification]): + list_display = ("id", "type", "owner", "created_at", "read_at") + + def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool: + return False + + def has_add_permission(self, request: HttpRequest, obj: Any = None) -> bool: + return False diff --git a/python/apps/taiga/src/taiga/notifications/api.py b/python/apps/taiga/src/taiga/notifications/api.py new file mode 100644 index 000000000..6d8a021d6 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/api.py @@ -0,0 +1,57 @@ +# -*- 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.api import AuthRequest +from taiga.base.api.permissions import check_permissions +from taiga.exceptions.api.errors import ERROR_403 +from taiga.notifications import services as notifications_services +from taiga.notifications.models import Notification +from taiga.notifications.serializers import NotificationCountersSerializer, NotificationSerializer +from taiga.permissions import IsAuthenticated +from taiga.routers import routes + +LIST_MY_NOTIFICATIONS = IsAuthenticated() +COUNT_MY_NOTIFICATIONS = IsAuthenticated() + +########################################################## +# list notifications +########################################################## + + +@routes.notifications.get( + "/my/notifications", + name="my.notifications.list", + summary="List all the user notifications", + responses=ERROR_403, + response_model=list[NotificationSerializer], +) +async def list_my_notifications(request: AuthRequest, is_read: bool | None = None) -> list[Notification]: + """ + List the notifications of the logged user. + """ + await check_permissions(permissions=LIST_MY_NOTIFICATIONS, user=request.user, obj=None) + return await notifications_services.list_user_notifications(user=request.user, is_read=is_read) + + +########################################################## +# count notifications +########################################################## + + +@routes.notifications.get( + "/my/notifications/count", + name="my.notifications.count", + summary="Get user notifications counters", + responses=ERROR_403, + response_model=NotificationCountersSerializer, +) +async def count_my_notifications(request: AuthRequest) -> dict[str, int]: + """ + List the notifications of the logged user. + """ + await check_permissions(permissions=COUNT_MY_NOTIFICATIONS, user=request.user, obj=None) + return await notifications_services.count_user_notifications(user=request.user) diff --git a/python/apps/taiga/src/taiga/notifications/events/__init__.py b/python/apps/taiga/src/taiga/notifications/events/__init__.py new file mode 100644 index 000000000..8e08a1605 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/events/__init__.py @@ -0,0 +1,25 @@ +# -*- 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.events import events_manager +from taiga.notifications.events.content import CreateNotificationContent +from taiga.notifications.models import Notification + +CREATE_NOTIFICATION = "notifications.create" + + +async def emit_event_when_notifications_are_created( + notifications: list[Notification], +) -> None: + for notification in notifications: + await events_manager.publish_on_user_channel( + user=notification.owner, + type=CREATE_NOTIFICATION, + content=CreateNotificationContent( + notification=notification, + ), + ) diff --git a/python/apps/taiga/src/taiga/notifications/events/content.py b/python/apps/taiga/src/taiga/notifications/events/content.py new file mode 100644 index 000000000..f89ce4c82 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/events/content.py @@ -0,0 +1,13 @@ +# -*- 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.notifications.serializers import NotificationSerializer + + +class CreateNotificationContent(BaseModel): + notification: NotificationSerializer diff --git a/python/apps/taiga/src/taiga/notifications/migrations/0001_initial.py b/python/apps/taiga/src/taiga/notifications/migrations/0001_initial.py new file mode 100644 index 000000000..ba759eb98 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/migrations/0001_initial.py @@ -0,0 +1,89 @@ +# -*- 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-04 18:15 + +import django.contrib.postgres.fields.jsonb +import django.db.models.deletion +import taiga.base.db.models +import taiga.base.utils.datetime +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Notification", + fields=[ + ( + "id", + models.UUIDField( + blank=True, + default=taiga.base.db.models.uuid_generator, + editable=False, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created_at", + models.DateTimeField( + default=taiga.base.utils.datetime.aware_utcnow, + verbose_name="created at", + ), + ), + ("type", models.CharField(max_length=500, verbose_name="type")), + ( + "read_at", + models.DateTimeField(blank=True, null=True, verbose_name="read at"), + ), + ( + "content", + django.contrib.postgres.fields.jsonb.JSONField(default=dict, verbose_name="content"), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="created by", + ), + ), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="notifications", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "notification", + "verbose_name_plural": "notifications", + "ordering": ["-created_at"], + "indexes": [ + models.Index(fields=["owner"], name="notificatio_owner_i_2bc47d_idx"), + models.Index( + fields=["owner", "read_at"], + name="notificatio_owner_i_37308f_idx", + ), + ], + }, + ), + ] diff --git a/python/apps/taiga/src/taiga/notifications/migrations/__init__.py b/python/apps/taiga/src/taiga/notifications/migrations/__init__.py new file mode 100644 index 000000000..87d9a5256 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/migrations/__init__.py @@ -0,0 +1,6 @@ +# -*- 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 diff --git a/python/apps/taiga/src/taiga/notifications/models.py b/python/apps/taiga/src/taiga/notifications/models.py new file mode 100644 index 000000000..e0dbd1b6e --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/models.py @@ -0,0 +1,54 @@ +# -*- 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.db import models +from taiga.base.db.mixins import CreatedMetaInfoMixin + +####################################################################### +# Base Notification +###################################################################### + + +class Notification(models.BaseModel, CreatedMetaInfoMixin): + type = models.CharField( + max_length=500, + null=False, + blank=False, + verbose_name="type", + ) + owner = models.ForeignKey( + "users.User", + null=False, + blank=False, + on_delete=models.CASCADE, + related_name="notifications", + verbose_name="owner", + ) + read_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="read at", + ) + content = models.JSONField( + null=False, + blank=False, + default=dict, + verbose_name="content", + ) + + class Meta: + verbose_name = "notification" + verbose_name_plural = "notifications" + ordering = ["-created_at"] + indexes = [ + models.Index( + fields=[ + "owner", + ] + ), + models.Index(fields=["owner", "read_at"]), + ] diff --git a/python/apps/taiga/src/taiga/notifications/repositories.py b/python/apps/taiga/src/taiga/notifications/repositories.py new file mode 100644 index 000000000..a2d80ad44 --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/repositories.py @@ -0,0 +1,92 @@ +# -*- 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 collections.abc import Iterable +from typing import Any, TypedDict + +from taiga.base.db.models import QuerySet +from taiga.notifications.models import Notification +from taiga.users.models import User + +########################################################## +# filters and querysets +########################################################## + +DEFAULT_QUERYSET = Notification.objects.select_related("created_by").all() + + +class NotificationFilters(TypedDict, total=False): + owner: User + is_read: bool + + +async def _apply_filters_to_queryset( + qs: QuerySet[Notification], + filters: NotificationFilters = {}, +) -> QuerySet[Notification]: + filter_data = dict(filters.copy()) + + if "is_read" in filter_data: + is_read = filter_data.pop("is_read") + filter_data["read_at__isnull"] = not is_read + + return qs.filter(**filter_data) + + +########################################################## +# create notifications +########################################################## + + +async def create_notifications( + owners: Iterable[User], + created_by: User, + notification_type: str, + content: dict[str, Any], +) -> list[Notification]: + notifications = [ + Notification( + owner=owner, + created_by=created_by, + type=notification_type, + content=content, + ) + for owner in owners + ] + + return await Notification.objects.abulk_create(notifications) + + +########################################################## +# list notifications +########################################################## + + +async def list_notifications( + filters: NotificationFilters = {}, + offset: int | None = None, + limit: int | None = None, +) -> list[Notification]: + qs = await _apply_filters_to_queryset(qs=DEFAULT_QUERYSET, filters=filters) + + if limit is not None and offset is not None: + limit += offset + + return [a async for a in qs[offset:limit]] + + +########################################################## +# misc +########################################################## + + +async def count_notifications( + filters: NotificationFilters = {}, +) -> int: + qs = await _apply_filters_to_queryset(qs=DEFAULT_QUERYSET, filters=filters) + + return await qs.acount() diff --git a/python/apps/taiga/src/taiga/notifications/serializers.py b/python/apps/taiga/src/taiga/notifications/serializers.py new file mode 100644 index 000000000..3dea465cf --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/serializers.py @@ -0,0 +1,28 @@ +# -*- 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 BaseModel, CamelizeDict +from taiga.users.serializers.nested import UserNestedSerializer + + +class NotificationSerializer(BaseModel): + type: str + created_by: UserNestedSerializer | None + created_at: datetime + read_at: datetime | None + content: CamelizeDict + + class Config: + orm_mode = True + + +class NotificationCountersSerializer(BaseModel): + read: int + unread: int + total: int diff --git a/python/apps/taiga/src/taiga/notifications/services.py b/python/apps/taiga/src/taiga/notifications/services.py new file mode 100644 index 000000000..51cfd545e --- /dev/null +++ b/python/apps/taiga/src/taiga/notifications/services.py @@ -0,0 +1,39 @@ +# -*- 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 collections.abc import Iterable + +from taiga.base.serializers import BaseModel +from taiga.notifications import events as notification_events +from taiga.notifications import repositories as notification_repositories +from taiga.notifications.models import Notification +from taiga.notifications.repositories import NotificationFilters +from taiga.users.models import User + + +async def notify_users(type: str, emitted_by: User, notified_users: Iterable[User], content: BaseModel) -> None: + notifications = await notification_repositories.create_notifications( + owners=notified_users, + created_by=emitted_by, + notification_type=type, + content=content.dict(), + ) + await notification_events.emit_event_when_notifications_are_created(notifications=notifications) + + +async def list_user_notifications(user: User, is_read: bool | None = None) -> list[Notification]: + filters: NotificationFilters = {"owner": user} + + if is_read is not None: + filters["is_read"] = is_read + + return await notification_repositories.list_notifications(filters=filters) + + +async def count_user_notifications(user: User) -> dict[str, int]: + total = await notification_repositories.count_notifications(filters={"owner": user}) + read = await notification_repositories.count_notifications(filters={"owner": user, "is_read": True}) + return {"total": total, "read": read, "unread": total - read} diff --git a/python/apps/taiga/src/taiga/projects/projects/serializers/nested.py b/python/apps/taiga/src/taiga/projects/projects/serializers/nested.py index 15c826f36..888a2b7c1 100644 --- a/python/apps/taiga/src/taiga/projects/projects/serializers/nested.py +++ b/python/apps/taiga/src/taiga/projects/projects/serializers/nested.py @@ -20,6 +20,15 @@ class Config: orm_mode = True +class ProjectLinkNestedSerializer(BaseModel): + id: UUIDB64 + name: str + slug: str + + class Config: + orm_mode = True + + class ProjectSmallNestedSerializer(BaseModel): id: UUIDB64 name: str diff --git a/python/apps/taiga/src/taiga/routers/loader.py b/python/apps/taiga/src/taiga/routers/loader.py index 188c8e786..4841f3575 100644 --- a/python/apps/taiga/src/taiga/routers/loader.py +++ b/python/apps/taiga/src/taiga/routers/loader.py @@ -13,6 +13,7 @@ from taiga.integrations.github.auth import api as github_auth_api # noqa from taiga.integrations.gitlab.auth import api as gitlab_auth_api # noqa from taiga.integrations.google.auth import api as google_auth_api # noqa +from taiga.notifications import api as notifications_api # noqa from taiga.projects.invitations import api as projects_invitations_api # noqa from taiga.projects.memberships import api as projects_memberships_api # noqa from taiga.projects.projects import api as projects_api # noqa @@ -39,6 +40,7 @@ def load_routes(api: FastAPI) -> None: api.include_router(routes.auth) api.include_router(routes.users) api.include_router(routes.unauth_users) + api.include_router(routes.notifications) api.include_router(routes.workspaces) api.include_router(routes.workspaces_invitations) api.include_router(routes.unauth_workspaces_invitations) diff --git a/python/apps/taiga/src/taiga/routers/routes.py b/python/apps/taiga/src/taiga/routers/routes.py index 3ad4cad44..4232bb2a9 100644 --- a/python/apps/taiga/src/taiga/routers/routes.py +++ b/python/apps/taiga/src/taiga/routers/routes.py @@ -32,6 +32,16 @@ } ) +# /notifications +notifications = AuthAPIRouter(tags=["notifications"]) +tags_metadata.append( + { + "name": "notifications", + "description": "Endpoint for user notifications resources.", + } +) + + # /workspaces workspaces = AuthAPIRouter(tags=["workspaces"]) tags_metadata.append( diff --git a/python/apps/taiga/src/taiga/stories/assignments/api/__init__.py b/python/apps/taiga/src/taiga/stories/assignments/api/__init__.py index 991da00e1..5d2ecd659 100644 --- a/python/apps/taiga/src/taiga/stories/assignments/api/__init__.py +++ b/python/apps/taiga/src/taiga/stories/assignments/api/__init__.py @@ -51,7 +51,10 @@ async def create_story_assignment( await check_permissions(permissions=CREATE_STORY_ASSIGNMENT, user=request.user, obj=story) return await story_assignments_services.create_story_assignment( - project_id=project_id, story=story, username=form.username + project_id=project_id, + story=story, + username=form.username, + created_by=request.user, ) @@ -77,9 +80,12 @@ async def delete_story_assignment( Delete a story assignment """ story_assignment = await get_story_assignment_or_404(project_id, ref, username) + story = await get_story_or_404(project_id, ref) await check_permissions(permissions=DELETE_STORY_ASSIGNMENT, user=request.user, obj=story_assignment.story) - await story_assignments_services.delete_story_assignment(story_assignment=story_assignment) + await story_assignments_services.delete_story_assignment( + story_assignment=story_assignment, story=story, deleted_by=request.user + ) ################################################ diff --git a/python/apps/taiga/src/taiga/stories/assignments/notifications/__init__.py b/python/apps/taiga/src/taiga/stories/assignments/notifications/__init__.py new file mode 100644 index 000000000..b4d34f73a --- /dev/null +++ b/python/apps/taiga/src/taiga/stories/assignments/notifications/__init__.py @@ -0,0 +1,55 @@ +# -*- 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.assignments.notifications.content import ( + StoryAssignNotificationContent, + StoryUnassignNotificationContent, +) +from taiga.stories.stories.models import Story +from taiga.users.models import User + +STORIES_ASSIGN = "stories.assign" +STORIES_UNASSIGN = "stories.unassign" + + +async def notify_when_story_is_assigned(story: Story, asigned_to: User, emitted_by: User) -> None: + """ + Emit notification when an story is assigned. + """ + notified_users = {asigned_to} + if story.created_by: + notified_users.add(story.created_by) + notified_users.discard(emitted_by) + + await notifications_services.notify_users( + type=STORIES_ASSIGN, + emitted_by=emitted_by, + notified_users=notified_users, + content=StoryAssignNotificationContent( + projects=story.project, story=story, assigned_by=emitted_by, assigned_to=asigned_to + ), + ) + + +async def notify_when_story_is_unassigned(story: Story, unassigned_to: User, emitted_by: User) -> None: + """ + Emit notification when story is unassigned. + """ + notified_users = {unassigned_to} + if story.created_by: + notified_users.add(story.created_by) + notified_users.discard(emitted_by) + + await notifications_services.notify_users( + type=STORIES_UNASSIGN, + emitted_by=emitted_by, + notified_users=notified_users, + content=StoryUnassignNotificationContent( + projects=story.project, story=story, unassigned_by=emitted_by, unassigned_to=unassigned_to + ), + ) diff --git a/python/apps/taiga/src/taiga/stories/assignments/notifications/content.py b/python/apps/taiga/src/taiga/stories/assignments/notifications/content.py new file mode 100644 index 000000000..ce2dac355 --- /dev/null +++ b/python/apps/taiga/src/taiga/stories/assignments/notifications/content.py @@ -0,0 +1,31 @@ +# -*- 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 StoryAssignNotificationContent(BaseModel): + projects: ProjectLinkNestedSerializer + story: StoryNestedSerializer + assigned_by: UserNestedSerializer + assigned_to: UserNestedSerializer + + class Config: + orm_mode = True + + +class StoryUnassignNotificationContent(BaseModel): + projects: ProjectLinkNestedSerializer + story: StoryNestedSerializer + unassigned_by: UserNestedSerializer + unassigned_to: UserNestedSerializer + + class Config: + orm_mode = True diff --git a/python/apps/taiga/src/taiga/stories/assignments/services/__init__.py b/python/apps/taiga/src/taiga/stories/assignments/services/__init__.py index 091d9cb69..30c85e495 100644 --- a/python/apps/taiga/src/taiga/stories/assignments/services/__init__.py +++ b/python/apps/taiga/src/taiga/stories/assignments/services/__init__.py @@ -9,17 +9,19 @@ from taiga.projects.memberships import repositories as pj_memberships_repositories from taiga.stories.assignments import events as stories_assignments_events +from taiga.stories.assignments import notifications as stories_assignments_notifications from taiga.stories.assignments import repositories as story_assignments_repositories from taiga.stories.assignments.models import StoryAssignment from taiga.stories.assignments.services import exceptions as ex from taiga.stories.stories.models import Story +from taiga.users.models import User ########################################################## # create story assignment ########################################################## -async def create_story_assignment(project_id: UUID, story: Story, username: str) -> StoryAssignment: +async def create_story_assignment(project_id: UUID, story: Story, username: str, created_by: User) -> StoryAssignment: pj_membership = await pj_memberships_repositories.get_project_membership( filters={"project_id": project_id, "username": username, "permissions": ["view_story"]} ) @@ -31,6 +33,9 @@ async def create_story_assignment(project_id: UUID, story: Story, username: str) story_assignment, created = await story_assignments_repositories.create_story_assignment(story=story, user=user) if created: await stories_assignments_events.emit_event_when_story_assignment_is_created(story_assignment=story_assignment) + await stories_assignments_notifications.notify_when_story_is_assigned( + story=story, asigned_to=user, emitted_by=created_by + ) return story_assignment @@ -52,9 +57,12 @@ async def get_story_assignment(project_id: UUID, ref: int, username: str) -> Sto ########################################################## -async def delete_story_assignment(story_assignment: StoryAssignment) -> bool: +async def delete_story_assignment(story_assignment: StoryAssignment, story: Story, deleted_by: User) -> bool: deleted = await story_assignments_repositories.delete_stories_assignments(filters={"id": story_assignment.id}) if deleted > 0: await stories_assignments_events.emit_event_when_story_assignment_is_deleted(story_assignment=story_assignment) + await stories_assignments_notifications.notify_when_story_is_unassigned( + story=story, unassigned_to=story_assignment.user, emitted_by=deleted_by + ) return True return False 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 eb771fb81..4aabcd5ac 100644 --- a/python/apps/taiga/src/taiga/stories/stories/services/__init__.py +++ b/python/apps/taiga/src/taiga/stories/stories/services/__init__.py @@ -109,7 +109,7 @@ async def list_paginated_stories( 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"], + select_related=["project", "workspace", "workflow", "created_by"], ) @@ -362,4 +362,4 @@ async def delete_story(story: Story, deleted_by: AnyUser) -> bool: ) return True - return False + return False \ No newline at end of file