diff --git a/python/apps/taiga/src/taiga/notifications/api.py b/python/apps/taiga/src/taiga/notifications/api.py index a27b0120a..50ba4a4a8 100644 --- a/python/apps/taiga/src/taiga/notifications/api.py +++ b/python/apps/taiga/src/taiga/notifications/api.py @@ -5,17 +5,23 @@ # # Copyright (c) 2023-present Kaleidos INC +from uuid import UUID + from taiga.base.api import AuthRequest from taiga.base.api.permissions import check_permissions -from taiga.exceptions.api.errors import ERROR_403 +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 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 +from taiga.users.models import User LIST_MY_NOTIFICATIONS = IsAuthenticated() COUNT_MY_NOTIFICATIONS = IsAuthenticated() +MARK_MY_NOTIFICATIONS_AS_READ = IsAuthenticated() ########################################################## # list notifications @@ -55,3 +61,37 @@ async def count_my_notifications(request: AuthRequest) -> dict[str, int]: """ await check_permissions(permissions=COUNT_MY_NOTIFICATIONS, user=request.user, obj=None) return await notifications_services.count_user_notifications(user=request.user) + + +########################################################## +# mark notification as read +########################################################## + + +@routes.notifications.post( + "/my/notifications/{id}/read", + name="my.notifications.read", + summary="Mark notification as read", + responses=ERROR_403 | ERROR_404 | ERROR_422, + response_model=NotificationSerializer, +) +async def mark_my_notification_as_read(id: B64UUID, request: AuthRequest) -> Notification: + """ + Mark a notification as read. + """ + await check_permissions(permissions=MARK_MY_NOTIFICATIONS_AS_READ, user=request.user, obj=None) + await get_notification_or_404(user=request.user, id=id) + return (await notifications_services.mark_user_notifications_as_read(user=request.user, id=id))[0] + + +########################################################## +# misc +########################################################## + + +async def get_notification_or_404(user: User, id: UUID) -> Notification: + notification = await notifications_services.get_user_notification(user=user, id=id) + if notification is None: + raise ex.NotFoundError("Notification does not exist") + + return notification diff --git a/python/apps/taiga/src/taiga/notifications/repositories.py b/python/apps/taiga/src/taiga/notifications/repositories.py index a2d80ad44..9d1b1b5e7 100644 --- a/python/apps/taiga/src/taiga/notifications/repositories.py +++ b/python/apps/taiga/src/taiga/notifications/repositories.py @@ -7,8 +7,10 @@ from collections.abc import Iterable from typing import Any, TypedDict +from uuid import UUID from taiga.base.db.models import QuerySet +from taiga.base.utils.datetime import aware_utcnow from taiga.notifications.models import Notification from taiga.users.models import User @@ -20,6 +22,7 @@ class NotificationFilters(TypedDict, total=False): + id: UUID owner: User is_read: bool @@ -79,6 +82,35 @@ async def list_notifications( return [a async for a in qs[offset:limit]] +########################################################## +# get notifications +########################################################## + + +async def get_notification( + filters: NotificationFilters = {}, +) -> Notification | None: + qs = await _apply_filters_to_queryset(qs=DEFAULT_QUERYSET, filters=filters) + + try: + return await qs.aget() + except Notification.DoesNotExist: + return None + + +########################################################## +# mark notificatiosn as read +########################################################## + + +async def mark_notifications_as_read( + filters: NotificationFilters = {}, +) -> list[Notification]: + qs = await _apply_filters_to_queryset(qs=DEFAULT_QUERYSET, filters=filters) + await qs.aupdate(read_at=aware_utcnow()) + return [a async for a in qs.all()] + + ########################################################## # misc ########################################################## diff --git a/python/apps/taiga/src/taiga/notifications/services.py b/python/apps/taiga/src/taiga/notifications/services.py index a0e970935..295059784 100644 --- a/python/apps/taiga/src/taiga/notifications/services.py +++ b/python/apps/taiga/src/taiga/notifications/services.py @@ -4,7 +4,9 @@ # file, You can obtain one at http://mozilla.org/MPL/2.0/. # # Copyright (c) 2023-present Kaleidos INC + from collections.abc import Iterable +from uuid import UUID from taiga.base.serializers import BaseModel from taiga.notifications import events as notifications_events @@ -33,6 +35,19 @@ async def list_user_notifications(user: User, is_read: bool | None = None) -> li return await notifications_repositories.list_notifications(filters=filters) +async def get_user_notification(user: User, id: UUID) -> Notification | None: + return await notifications_repositories.get_notification(filters={"owner": user, "id": id}) + + +async def mark_user_notifications_as_read(user: User, id: UUID | None = None) -> list[Notification]: + filters: NotificationFilters = {"owner": user} + + if id is not None: + filters["id"] = id + + return await notifications_repositories.mark_notifications_as_read(filters=filters) + + async def count_user_notifications(user: User) -> dict[str, int]: total = await notifications_repositories.count_notifications(filters={"owner": user}) read = await notifications_repositories.count_notifications(filters={"owner": user, "is_read": True}) diff --git a/python/apps/taiga/tests/integration/taiga/notifications/test_api.py b/python/apps/taiga/tests/integration/taiga/notifications/test_api.py index b65875271..17096e423 100644 --- a/python/apps/taiga/tests/integration/taiga/notifications/test_api.py +++ b/python/apps/taiga/tests/integration/taiga/notifications/test_api.py @@ -9,6 +9,7 @@ from fastapi import status from taiga.base.utils.datetime import aware_utcnow from tests.utils import factories as f +from tests.utils.bad_params import INVALID_B64ID pytestmark = pytest.mark.django_db(transaction=True) @@ -61,6 +62,46 @@ async def test_list_my_notifications_403_forbidden_error(client): assert response.status_code == status.HTTP_403_FORBIDDEN, response.text +########################################################## +# POST my/notifications/{id}/read +########################################################## + + +async def test_mark_notification_as_read_200_ok(client): + user = await f.create_user() + notification = await f.create_notification(owner=user) + + client.login(user) + response = client.post(f"/my/notifications/{notification.b64id}/read") + assert response.status_code == status.HTTP_200_OK, response.text + assert response.json()["readAt"] is not None, response.json() + + +async def test_mark_my_notification_as_read_404_not_found(client): + user = await f.create_user() + notification = await f.create_notification() + + client.login(user) + response = client.post(f"/my/notifications/{notification.b64id}/read") + assert response.status_code == status.HTTP_404_NOT_FOUND, response.text + + +async def test_mark_my_notification_as_read_403_forbidden_error(client): + user = await f.create_user() + notification = await f.create_notification(owner=user) + + response = client.post(f"/my/notifications/{notification.b64id}/read") + assert response.status_code == status.HTTP_403_FORBIDDEN, response.text + + +async def test_mark_my_notification_as_read_422_unprocessable_entity(client): + user = await f.create_user() + + client.login(user) + response = client.post(f"/my/notifications/{INVALID_B64ID}/read") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY, response.text + + ########################################################## # GET my/notifications/count ########################################################## diff --git a/python/apps/taiga/tests/integration/taiga/notifications/test_repositories.py b/python/apps/taiga/tests/integration/taiga/notifications/test_repositories.py index 34274a7a6..52c92da0b 100644 --- a/python/apps/taiga/tests/integration/taiga/notifications/test_repositories.py +++ b/python/apps/taiga/tests/integration/taiga/notifications/test_repositories.py @@ -62,6 +62,39 @@ async def test_list_notifications_filters(): assert [n22, n12] == await repositories.list_notifications(filters={"is_read": True}) +########################################################## +# get_notification +########################################################## + + +async def test_get_notification(): + user1 = await f.create_user() + user2 = await f.create_user() + notification = await f.create_notification(owner=user1) + + assert await repositories.get_notification(filters={"id": notification.id}) == notification + assert await repositories.get_notification(filters={"id": notification.id, "owner": user1}) == notification + assert await repositories.get_notification(filters={"id": notification.id, "owner": user2}) is None + + +########################################################## +# mark notifications as read +########################################################## + + +async def test_mark_notifications_as_read(): + user = await f.create_user() + n1 = await f.create_notification(owner=user) + n2 = await f.create_notification(owner=user) + n3 = await f.create_notification(owner=user) + + assert n1.read_at == n2.read_at == n3.read_at is None + + ns = await repositories.mark_notifications_as_read(filters={"owner": user}) + + assert ns[0].read_at == ns[1].read_at == ns[2].read_at is not None + + ########################################################## # misc ########################################################## diff --git a/python/apps/taiga/tests/utils/factories/notifications.py b/python/apps/taiga/tests/utils/factories/notifications.py index 54daa5794..2da92b49b 100644 --- a/python/apps/taiga/tests/utils/factories/notifications.py +++ b/python/apps/taiga/tests/utils/factories/notifications.py @@ -7,11 +7,12 @@ from asgiref.sync import sync_to_async -from .base import Factory +from .base import Factory, factory class NotificationFactory(Factory): type = "test_notification" + owner = factory.SubFactory("tests.utils.factories.UserFactory") class Meta: model = "notifications.Notification"