Skip to content

Commit

Permalink
feat(notifications): u#3965 add a periodic task and a command to dele…
Browse files Browse the repository at this point in the history
…te read notifications
  • Loading branch information
bameda committed Nov 23, 2023
1 parent f276b45 commit 9cde441
Show file tree
Hide file tree
Showing 11 changed files with 188 additions and 4 deletions.
4 changes: 3 additions & 1 deletion python/apps/taiga/src/taiga/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from taiga.base.sampledata.commands import cli as sampledata_cli
from taiga.commons.storage.commands import cli as storage_cli
from taiga.emails.commands import cli as emails_cli
from taiga.notifications.commands import cli as notifications_cli
from taiga.tasksqueue.commands import cli as tasksqueue_cli
from taiga.tasksqueue.commands import init as init_tasksqueue
from taiga.tasksqueue.commands import run_worker, worker
Expand Down Expand Up @@ -69,9 +70,10 @@ def main(
cli.add_typer(db_cli, name="db")
cli.add_typer(emails_cli, name="emails")
cli.add_typer(i18n_cli, name="i18n")
cli.add_typer(notifications_cli, name="notifications")
cli.add_typer(sampledata_cli, name="sampledata")
cli.add_typer(tasksqueue_cli, name="tasksqueue")
cli.add_typer(storage_cli, name="storage")
cli.add_typer(tasksqueue_cli, name="tasksqueue")
cli.add_typer(tokens_cli, name="tokens")
cli.add_typer(users_cli, name="users")

Expand Down
2 changes: 1 addition & 1 deletion python/apps/taiga/src/taiga/commons/storage/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def clean_storaged_objects(
settings.STORAGE.DAYS_TO_STORE_DELETED_STORAGED_OBJECTS,
"--days",
"-d",
help="Number of days to store deleted storaged objects",
help="Delete all storaged object deleted before the specified days",
),
) -> None:
total_deleted = run_async_as_sync(
Expand Down
2 changes: 2 additions & 0 deletions python/apps/taiga/src/taiga/conf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from taiga.conf.events import EventsSettings
from taiga.conf.images import ImageSettings
from taiga.conf.logs import LOGGING_CONFIG
from taiga.conf.notifications import NotificationsSettings
from taiga.conf.storage import StorageSettings
from taiga.conf.tasksqueue import TaskQueueSettings
from taiga.conf.tokens import TokensSettings
Expand Down Expand Up @@ -102,6 +103,7 @@ class Settings(BaseSettings):
EMAIL: EmailSettings = EmailSettings()
EVENTS: EventsSettings = EventsSettings()
IMAGES: ImageSettings = ImageSettings()
NOTIFICATIONS: NotificationsSettings = NotificationsSettings()
STORAGE: StorageSettings = StorageSettings()
TASKQUEUE: TaskQueueSettings = TaskQueueSettings()
TOKENS: TokensSettings = TokensSettings()
Expand Down
13 changes: 13 additions & 0 deletions python/apps/taiga/src/taiga/conf/notifications.py
Original file line number Diff line number Diff line change
@@ -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 pydantic import BaseSettings


class NotificationsSettings(BaseSettings):
CLEAN_READ_NOTIFICATIONS_CRON: str = "30 * * * *" # default: every hour at minute 30.
MINUTES_TO_STORE_READ_NOTIFICATIONS: int = 2 * 60 # 120 minutes
1 change: 1 addition & 0 deletions python/apps/taiga/src/taiga/conf/tasksqueue.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class TaskQueueSettings(BaseSettings):
TASKS_MODULES_PATHS: set[str] = {
"taiga.commons.storage.tasks",
"taiga.emails.tasks",
"taiga.notifications.tasks",
"taiga.projects.projects.tasks",
"taiga.tokens.tasks",
"taiga.users.tasks",
Expand Down
40 changes: 40 additions & 0 deletions python/apps/taiga/src/taiga/notifications/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# -*- 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 timedelta

import typer
from taiga.base.utils import pprint
from taiga.base.utils.concurrency import run_async_as_sync
from taiga.base.utils.datetime import aware_utcnow
from taiga.conf import settings
from taiga.notifications import services as notifications_services

cli = typer.Typer(
name="Taiga Notifications commands",
help="Manage the notifications system of Taiga.",
add_completion=True,
)


@cli.command(help="Clean read notifications. Remove entries from DB.")
def clean_read_notifications(
minutes_to_store_read_notifications: int = typer.Option(
settings.NOTIFICATIONS.MINUTES_TO_STORE_READ_NOTIFICATIONS,
"--minutes",
"-m",
help="Delete all notification read before the specified minutes",
),
) -> None:
total_deleted = run_async_as_sync(
notifications_services.clean_read_notifications(
before=aware_utcnow() - timedelta(minutes=minutes_to_store_read_notifications)
)
)

color = "red" if total_deleted else "white"
pprint.print(f"Deleted [bold][{color}]{total_deleted}[/{color}][/bold] notifications.")
16 changes: 16 additions & 0 deletions python/apps/taiga/src/taiga/notifications/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# Copyright (c) 2023-present Kaleidos INC

from collections.abc import Iterable
from datetime import datetime
from typing import Any, TypedDict
from uuid import UUID

Expand All @@ -25,6 +26,7 @@ class NotificationFilters(TypedDict, total=False):
id: UUID
owner: User
is_read: bool
read_before: datetime


async def _apply_filters_to_queryset(
Expand All @@ -36,6 +38,9 @@ async def _apply_filters_to_queryset(
if "is_read" in filter_data:
is_read = filter_data.pop("is_read")
filter_data["read_at__isnull"] = not is_read
if "read_before" in filter_data:
read_before = filter_data.pop("read_before")
filter_data["read_at__lt"] = read_before

return qs.filter(**filter_data)

Expand Down Expand Up @@ -111,6 +116,17 @@ async def mark_notifications_as_read(
return [a async for a in qs.all()]


##########################################################
# delete notifications
##########################################################


async def delete_notifications(filters: NotificationFilters = {}) -> int:
qs = await _apply_filters_to_queryset(qs=DEFAULT_QUERYSET, filters=filters)
count, _ = await qs.adelete()
return count


##########################################################
# misc
##########################################################
Expand Down
5 changes: 5 additions & 0 deletions python/apps/taiga/src/taiga/notifications/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# Copyright (c) 2023-present Kaleidos INC

from collections.abc import Iterable
from datetime import datetime
from uuid import UUID

from taiga.base.serializers import BaseModel
Expand Down Expand Up @@ -57,3 +58,7 @@ 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})
return {"total": total, "read": read, "unread": total - read}


async def clean_read_notifications(before: datetime) -> int:
return await notifications_repositories.delete_notifications(filters={"is_read": True, "read_before": before})
32 changes: 32 additions & 0 deletions python/apps/taiga/src/taiga/notifications/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -*- 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

import logging
from datetime import timedelta

from taiga.base.utils.datetime import aware_utcnow
from taiga.conf import settings
from taiga.notifications import services as notifications_services
from taiga.tasksqueue.manager import manager as tqmanager

logger = logging.getLogger(__name__)


@tqmanager.periodic(cron=settings.NOTIFICATIONS.CLEAN_READ_NOTIFICATIONS_CRON) # type: ignore
@tqmanager.task
async def clean_read_notifications(timestamp: int) -> int:
total_deleted = await notifications_services.clean_read_notifications(
before=aware_utcnow() - timedelta(minutes=settings.NOTIFICATIONS.MINUTES_TO_STORE_READ_NOTIFICATIONS)
)

logger.info(
"deleted notifications: %s",
total_deleted,
extra={"deleted": total_deleted},
)

return total_deleted
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
#
# Copyright (c) 2023-present Kaleidos INC

from datetime import timedelta

import pytest
from taiga.base.utils.datetime import aware_utcnow
from taiga.notifications import repositories
Expand Down Expand Up @@ -48,18 +50,21 @@ async def test_list_notifications_filters():
user2 = await f.create_user()
user3 = await f.create_user()

now = aware_utcnow()

n11 = await f.create_notification(owner=user1, created_by=user3)
n12 = await f.create_notification(owner=user1, created_by=user3, read_at=aware_utcnow())
n12 = await f.create_notification(owner=user1, created_by=user3, read_at=now - timedelta(minutes=2))
n13 = await f.create_notification(owner=user1, created_by=user3)

n21 = await f.create_notification(owner=user2, created_by=user3)
n22 = await f.create_notification(owner=user2, created_by=user3, read_at=aware_utcnow())
n22 = await f.create_notification(owner=user2, created_by=user3, read_at=now - timedelta(minutes=1))

assert [n22, n21, n13, n12, n11] == await repositories.list_notifications()
assert [n13, n12, n11] == await repositories.list_notifications(filters={"owner": user1})
assert [n13, n11] == await repositories.list_notifications(filters={"owner": user1, "is_read": False})
assert [n12] == await repositories.list_notifications(filters={"owner": user1, "is_read": True})
assert [n22, n12] == await repositories.list_notifications(filters={"is_read": True})
assert [n12] == await repositories.list_notifications(filters={"read_before": now - timedelta(minutes=1)})


##########################################################
Expand Down Expand Up @@ -95,6 +100,48 @@ async def test_mark_notifications_as_read():
assert ns[0].read_at == ns[1].read_at == ns[2].read_at is not None


##########################################################
# delete notifications
##########################################################


async def test_delete_notifications():
user1 = await f.create_user()
user2 = await f.create_user()
user3 = await f.create_user()

now = aware_utcnow()

await f.create_notification(owner=user1, created_by=user3)
await f.create_notification(owner=user1, created_by=user3, read_at=now - timedelta(minutes=1))
await f.create_notification(owner=user1, created_by=user3, read_at=now - timedelta(minutes=2))

await f.create_notification(owner=user2, created_by=user3)
await f.create_notification(owner=user2, created_by=user3, read_at=now - timedelta(minutes=1))

assert 5 == await repositories.count_notifications()
assert 3 == await repositories.count_notifications(filters={"owner": user1})
assert 2 == await repositories.count_notifications(filters={"owner": user1, "is_read": True})
assert 2 == await repositories.count_notifications(filters={"owner": user2})
assert 1 == await repositories.count_notifications(filters={"owner": user2, "is_read": True})

await repositories.delete_notifications(filters={"read_before": now - timedelta(minutes=1)})

assert 4 == await repositories.count_notifications()
assert 2 == await repositories.count_notifications(filters={"owner": user1})
assert 1 == await repositories.count_notifications(filters={"owner": user1, "is_read": True})
assert 2 == await repositories.count_notifications(filters={"owner": user2})
assert 1 == await repositories.count_notifications(filters={"owner": user2, "is_read": True})

await repositories.delete_notifications(filters={"read_before": now})

assert 2 == await repositories.count_notifications()
assert 1 == await repositories.count_notifications(filters={"owner": user1})
assert 0 == await repositories.count_notifications(filters={"owner": user1, "is_read": True})
assert 1 == await repositories.count_notifications(filters={"owner": user2})
assert 0 == await repositories.count_notifications(filters={"owner": user2, "is_read": True})


##########################################################
# misc
##########################################################
Expand Down
26 changes: 26 additions & 0 deletions python/apps/taiga/tests/unit/taiga/notifications/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from unittest.mock import call, patch

from taiga.base.serializers import BaseModel
from taiga.base.utils.datetime import aware_utcnow
from taiga.notifications import services
from tests.utils import factories as f

Expand Down Expand Up @@ -142,6 +143,31 @@ async def test_mark_user_notifications_as_read_with_many():
)


#####################################################################
# clean_read_notifications
#####################################################################


async def test_clean_read_notifications():
now = aware_utcnow()

with (
patch(
"taiga.notifications.services.notifications_repositories", autospec=True
) as fake_notifications_repository,
):
fake_notifications_repository.delete_notifications.return_value = 1

assert await services.clean_read_notifications(before=now) == 1

fake_notifications_repository.delete_notifications.assert_called_once_with(
filters={
"is_read": True,
"read_before": now,
}
)


#####################################################################
# count_user_notifications
#####################################################################
Expand Down

0 comments on commit 9cde441

Please sign in to comment.