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 b0f8cf2 commit 72c6974
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

1 comment on commit 72c6974

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/taiga
   __main__.py73730%17–157
src/taiga/attachments
   admin.py27390%25, 28, 70
   events.py8275%15, 20
   models.py19289%61, 64
   repositories.py49197%62
src/taiga/auth/services
   __init__.py47098%90–>93
src/taiga/base
   mocks.py741280%93–94, 107–118, 143–>exit
src/taiga/base/api
   permissions.py49199%22
   requests.py11380%15, 21–23
src/taiga/base/db
   __init__.py4175%15
   commands.py52520%8–269
   sequences.py45297%91–92
src/taiga/base/db/admin
   forms.py14838%17–25
   utils.py11736%17–23
src/taiga/base/db/models
   fields.py15188%20
src/taiga/base/django
   __init__.py5260%17–19
   settings.py30293%16–17
   urls.py330%8–11
src/taiga/base/i18n
   __init__.py102988%171–>170, 173–>170, 192–193, 203–205, 215, 268, 279, 283
   choices.py34291%80, 87
   commands.py74740%8–108
src/taiga/base/logging
   formatters.py241050%22–>exit, 38, 44–45, 51–60
   middlewares.py23196%20
src/taiga/base/repositories
   neighbors.py39391%43–44, 65
src/taiga/base/sampledata
   commands.py11110%8–40
   constants.py15150%14–51
   demo_data.py2732730%8–529
   factories.py1221220%9–353
   test_data.py2902900%8–634
src/taiga/base/serializers
   fields.py34393%22, 47, 61
src/taiga/base/utils
   commands.py11110%8–30
   datetime.py441860%54, 67, 126–147
   enum.py14192%43
   images.py26387%21–22, 30
   json.py33963%31–>33, 33–>35, 37, 39–44, 46, 50
   pprint.py660%8–17
   slug.py23485%22–25
src/taiga/base/validators/fields
   i18n.py15195%19
   uuid.py14195%19
src/taiga/comments
   admin.py29392%16, 20, 24
   events.py11373%15, 20, 25
   models.py20291%43, 46
   notifications.py6183%16
src/taiga/commons/storage
   commands.py13130%8–40
   models.py16288%38, 41
   services.py9092%17–>16
   tasks.py990%8–19
src/taiga/conf
   __init__.py96395%114, 130–131
   tokens.py17190%34
src/taiga/emails
   commands.py35350%9–68
   sender.py18571%48–50, 54–73
   tasks.py39490%44–45, 53–54
src/taiga/events
   app.py18192%21
   events.py16288%31–32
   logging.py392331%18–>exit, 32–48, 51–60
   manager.py1171190%78–79, 223–233, 239–242
   responses.py33684%33, 37–41
   subscriber.py80495%51, 55, 88, 91, 108–>120
src/taiga/events/actions
   auth.py231144%27–32, 39–45
   event_handlers.py9456%18–20, 28–30
   projects.py553038%36–40, 48–64, 72–80, 88–96
   workspaces.py553038%36–40, 48–64, 72–80, 88–96
src/taiga/events/pubsub/backends
   base.py30198%53
   redis.py66594%41–43, 85–86
src/taiga/exceptions/api
   __init__.py27097%28–>30
   handlers.py27290%27–28
   middlewares.py26287%37, 41
src/taiga/integrations/auth
   services.py39782%50–56
src/taiga/integrations/github
   services.py40098%63–>68
src/taiga/mediafiles
   admin.py20291%24, 27
   models.py26292%84, 87
src/taiga/notifications
   admin.py11285%20, 23
   api.py33195%95
   commands.py13130%8–40
   repositories.py48197%85
   services.py33097%51–>54
   tasks.py13130%8–32
src/taiga/permissions
   services.py103198%28, 106–>109
src/taiga/permissions/validators
   fields.py24388%18–19, 45
src/taiga/projects/invitations
   models.py25292%89, 92
   repositories.py84198%135
src/taiga/projects/invitations/api
   validators.py65789%38–40, 65–66, 84–85
src/taiga/projects/invitations/events
   __init__.py33191%37–>exit, 49–>exit, 74–>exit, 102
src/taiga/projects/invitations/services
   __init__.py183199%58–>53, 286
src/taiga/projects/memberships
   models.py16288%51, 54
   repositories.py86297%91, 124
src/taiga/projects/memberships/events
   __init__.py16288%18–24
src/taiga/projects/memberships/services
   __init__.py45195%64–>69, 111
src/taiga/projects/projects
   admin.py531275%33–34, 39–42, 50–51, 56–59, 90, 94
   models.py61886%64, 67, 78, 113, 116, 119–121
   repositories.py115496%125, 126–>129, 155, 254–255
   tasks.py6188%17
src/taiga/projects/projects/services
   __init__.py107592%78, 87, 199, 205–>208, 213, 235
src/taiga/projects/references
   mixins.py13093%25–>28
src/taiga/projects/roles
   api.py35393%46–48
   models.py26386%50, 53, 57
   repositories.py44196%92
src/taiga/projects/roles/services
   __init__.py25194%24, 57–>62
src/taiga/stories/assignments
   models.py15287%42, 45
   repositories.py52197%49
src/taiga/stories/assignments/notifications
   __init__.py18091%25–>27, 44–>46
src/taiga/stories/attachments
   api.py51197%159
   serializers.py11093%20–>28
src/taiga/stories/comments
   api.py59197%188
src/taiga/stories/comments/notifications
   __init__.py12094%19–>21
src/taiga/stories/stories
   admin.py25584%76–81, 85, 89
   models.py25292%73, 76
src/taiga/stories/stories/notifications
   __init__.py25091%27–>29, 49–>51, 72–>74
src/taiga/stories/stories/services
   __init__.py150298%251–>268, 312, 336
src/taiga/tasksqueue
   app.py20286%29–30
   commands.py65650%8–122
   logging.py331740%18–>exit, 29–39, 42–51
   manager.py561377%37, 41, 45, 49, 94, 163, 177, 186, 191, 198, 202, 209, 213
   task.py12192%23
src/taiga/tokens
   admin.py441081%67, 70, 73, 76, 104, 108, 112, 116, 120, 124
   models.py30487%59, 62, 78, 81
src/taiga/users
   admin.py51781%32, 41, 50, 87–95
   commands.py990%8–26
   models.py651086%71, 74, 90, 93, 97, 100, 103, 106, 127, 130
   repositories.py204398%209–>212, 335, 428–429
src/taiga/users/api
   __init__.py66296%216, 304
src/taiga/users/api/validators
   __init__.py42392%39–41
src/taiga/users/services
   __init__.py244795%75, 259, 262–>257, 297–>280, 306–>280, 322–>318, 345–>341, 366, 494–>499, 513–520, 521–>526, 524–525
src/taiga/workflows
   models.py40490%42, 45, 85, 88
src/taiga/workflows/services
   __init__.py156595%54, 233, 329, 373–>403, 380, 466–>475, 482
src/taiga/workspaces/invitations
   models.py24292%84, 87
   permissions.py9182%19
   repositories.py77198%123
src/taiga/workspaces/invitations/api
   validators.py18288%24–25
src/taiga/workspaces/invitations/events
   __init__.py25191%36–>exit, 60–>exit, 71
src/taiga/workspaces/invitations/services
   __init__.py123199%222
src/taiga/workspaces/memberships
   api.py43196%126
   models.py15287%43, 46
   repositories.py63295%72, 105
src/taiga/workspaces/memberships/services
   __init__.py46197%117
src/taiga/workspaces/workspaces
   admin.py571962%25–26, 31–34, 49, 52–57, 61, 65, 73–74, 79–82
   models.py18290%32, 35
   repositories.py127299%232–233
src/taiga/workspaces/workspaces/services
   __init__.py54294%113, 116
TOTAL10384157284% 

Tests Skipped Failures Errors Time
1192 1 💤 0 ❌ 0 🔥 11m 20s ⏱️

Please sign in to comment.