Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
ae98b96
add is_digest_type property
Ostap-Zherebetskyi Jan 20, 2026
73d87ca
Add is_digest_type property to NotificationType and log message in em…
Ostap-Zherebetskyi Jan 20, 2026
d302576
Fix is_digest handling in NotificationType.emit method
Ostap-Zherebetskyi Jan 20, 2026
a6c9576
Log message update in NotificationType.emit method for is_digest hand…
Ostap-Zherebetskyi Jan 20, 2026
bc3f05f
fix unit tests
Ostap-Zherebetskyi Jan 20, 2026
daaa834
fix unit tests
Ostap-Zherebetskyi Jan 20, 2026
807f652
fix unit tests
Ostap-Zherebetskyi Jan 20, 2026
f0b7056
Add fake_sent field to Notification model and update notification cre…
Ostap-Zherebetskyi Jan 8, 2026
ce14838
add unique_together constraint
Ostap-Zherebetskyi Jan 8, 2026
0e3f9bc
Add 'PARTIAL_SUCCESS' status to EmailTask model and update email task…
Ostap-Zherebetskyi Jan 8, 2026
e90bb1c
NR migration [ENG-10040, ENG-10025, ENG-9854]
Ostap-Zherebetskyi Jan 8, 2026
08e1ea2
Remove subscription if notifications.tasks.send_moderator_email_task …
Ostap-Zherebetskyi Jan 8, 2026
ab06f57
Apply suggestion from @Ostap-Zherebetskyi remove datetime
Ostap-Zherebetskyi Jan 19, 2026
ced4078
Merge pull request #11558 from Ostap-Zherebetskyi/feature/digest_types
cslzchen Jan 21, 2026
c0f944f
Add 'no_login_email_last_sent' field to OSFUser and update email task…
Ostap-Zherebetskyi Jan 22, 2026
8f51b4a
Implement notifications cleanup task and related settings; add tests …
Ostap-Zherebetskyi Jan 23, 2026
7f475ab
removed email.py
bodintsov Jan 26, 2026
8687a5a
clear useless code
bodintsov Jan 27, 2026
d668c6b
clear useless code
bodintsov Jan 27, 2026
cd80036
Rename migration name for NR post-release
cslzchen Jan 27, 2026
6bc138c
Improve unit test: test_emit_frequency_none
cslzchen Jan 27, 2026
09a570d
Remove `seen` from `Notification` and re-make migrations
cslzchen Jan 27, 2026
c5fa8bc
`mark_sent()` now handles `fake_sent=True`, and only `save()` once
cslzchen Jan 27, 2026
f857791
Update default settings
Ostap-Zherebetskyi Jan 28, 2026
25c2637
remove useless import
bodintsov Jan 28, 2026
f6694d8
Merge pull request #11540 from Ostap-Zherebetskyi/hotfix/NR_model_mig…
cslzchen Jan 28, 2026
b864ef1
Merge pull request #11562 from Ostap-Zherebetskyi/feature/notificatio…
cslzchen Jan 28, 2026
132cd8d
Enhance SubscriptionList queryset with additional content types and r…
Ostap-Zherebetskyi Jan 27, 2026
6999f4f
fix unit test
Ostap-Zherebetskyi Jan 27, 2026
b925732
Refactor SubscriptionList queryset to use a single provider content t…
Ostap-Zherebetskyi Jan 28, 2026
5785107
fix CR comments
Ostap-Zherebetskyi Jan 28, 2026
ccfeda1
Merge pull request #11564 from Ostap-Zherebetskyi/fix/SubscriptionLis…
cslzchen Jan 28, 2026
f5b0202
Update comments
cslzchen Jan 28, 2026
3adf193
Merge pull request #11565 from bodintsov/fix/remove-deprecated-code
cslzchen Jan 28, 2026
7371ed3
split into 3 files
bodintsov Jan 15, 2026
35737a7
remove populate_notification_subscriptions
bodintsov Jan 15, 2026
cc7fc29
Renamed files, refactor of populate notification subscriptions user g…
bodintsov Jan 20, 2026
5fef52a
added try/except, added timers
bodintsov Jan 20, 2026
93d98db
converted populate_notification_subscriptions_user_global_reviews.py
bodintsov Jan 21, 2026
7f8f705
fix batch time execution
bodintsov Jan 22, 2026
e38048b
converted populate_notification_subscriptions_node_file_updated
bodintsov Jan 23, 2026
434160f
convert to separate update and create scripts
bodintsov Jan 23, 2026
f2c5667
updated, added parameters
bodintsov Jan 27, 2026
f89e0b3
move to remove_after_use
bodintsov Jan 28, 2026
38a07fe
add time track to last batch, fix proper time track for batch in node…
bodintsov Jan 30, 2026
799bedb
convert to use review_nt
bodintsov Jan 30, 2026
c9cffee
fix names of tasks
bodintsov Feb 3, 2026
00702ef
Merge pull request #11560 from bodintsov/feature/optimize-subscriptio…
cslzchen Feb 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions api/subscriptions/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.db.models import Value, When, Case, OuterRef, Subquery
from django.db.models import Value, When, Case, OuterRef, Subquery, F
from django.db.models.fields import CharField, IntegerField
from django.db.models.functions import Concat, Cast
from django.contrib.contenttypes.models import ContentType
Expand Down Expand Up @@ -46,6 +46,10 @@ class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin):
def get_queryset(self):
user_guid = self.request.user._id

provider_ct = ContentType.objects.get_by_natural_key(app_label='osf', model='abstractprovider')
node_ct = ContentType.objects.get_by_natural_key(app_label='osf', model='abstractnode')
user_ct = ContentType.objects.get_by_natural_key(app_label='osf', model='osfuser')

node_subquery = AbstractNode.objects.filter(
id=Cast(OuterRef('object_id'), IntegerField()),
).values('guids___id')[:1]
Expand All @@ -60,11 +64,13 @@ def get_queryset(self):
NotificationType.Type.ADDON_FILE_REMOVED.value,
NotificationType.Type.FOLDER_CREATED.value,
]
_global_reviews = [
_global_reviews_provider = [
NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.value,
NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION.value,
NotificationType.Type.PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION.value,
NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS.value,
]
_global_reviews_user = [
NotificationType.Type.REVIEWS_SUBMISSION_STATUS.value,
]
_node_file_updated = [
Expand All @@ -80,27 +86,31 @@ def get_queryset(self):
]

qs = NotificationSubscription.objects.filter(
notification_type__name__in=[
NotificationType.Type.USER_FILE_UPDATED.value,
NotificationType.Type.NODE_FILE_UPDATED.value,
NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.value,
] + _global_reviews + _global_file_updated + _node_file_updated,
notification_type__name__in=_global_reviews_provider + _global_reviews_user + _global_file_updated + _node_file_updated,
user=self.request.user,
).annotate(
event_name=Case(
When(
notification_type__name__in=_node_file_updated,
content_type=node_ct,
then=Value('files_updated'),
),
When(
notification_type__name__in=_global_file_updated,
content_type=user_ct,
then=Value('global_file_updated'),
),
When(
notification_type__name__in=_global_reviews,
notification_type__name__in=_global_reviews_provider,
content_type=provider_ct,
then=Value('global_reviews'),
),
When(
notification_type__name__in=_global_reviews_user,
content_type=user_ct,
then=Value('global_reviews'),
),
default=Value('notification_type__name'),
default=F('notification_type__name'),
),
legacy_id=Case(
When(
Expand All @@ -112,10 +122,16 @@ def get_queryset(self):
then=Value(f'{user_guid}_global_file_updated'),
),
When(
notification_type__name__in=_global_reviews,
notification_type__name__in=_global_reviews_provider,
content_type=provider_ct,
then=Value(f'{user_guid}_global_reviews'),
),
When(
notification_type__name__in=_global_reviews_user,
content_type=user_ct,
then=Value(f'{user_guid}_global_reviews'),
),
default=Value('notification_type__name'),
default=F('notification_type__name'),
),
).distinct('legacy_id')

Expand Down
16 changes: 9 additions & 7 deletions api_tests/guids/views/test_guid_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
PrivateLinkFactory,
)
from website.settings import API_DOMAIN
from tests.utils import capture_notifications


@pytest.mark.django_db
Expand All @@ -32,13 +33,14 @@ def registration(self):
@pytest.fixture()
def versioned_preprint(self, user):
preprint = PreprintFactory(reviews_workflow='pre-moderation')
PreprintFactory.create_version(
create_from=preprint,
creator=user,
final_machine_state='accepted',
is_published=True,
set_doi=False
)
with capture_notifications():
PreprintFactory.create_version(
create_from=preprint,
creator=user,
final_machine_state='accepted',
is_published=True,
set_doi=False
)
return preprint

def test_redirects(self, app, project, registration, user):
Expand Down
18 changes: 3 additions & 15 deletions api_tests/mailhog/provider/test_preprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from osf import features

from framework.auth.core import Auth
from osf.models import NotificationType, Notification
from osf.models import NotificationType
from osf_tests.factories import (
ProjectFactory,
AuthUserFactory,
Expand All @@ -12,7 +12,6 @@
from osf.utils.permissions import WRITE
from tests.base import OsfTestCase
from tests.utils import get_mailhog_messages, delete_mailhog_messages, capture_notifications, assert_emails
from notifications.tasks import send_users_instant_digest_email


class TestPreprintConfirmationEmails(OsfTestCase):
Expand All @@ -35,25 +34,14 @@ def test_creator_gets_email(self):
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION
messages = get_mailhog_messages()
assert not messages['items']
assert Notification.objects.all()
with capture_notifications(passthrough=True) as notifications:
send_users_instant_digest_email.delay()

messages = get_mailhog_messages()
assert messages['count'] == len(notifications['emits'])
assert_emails(messages, notifications)

delete_mailhog_messages()
with capture_notifications(passthrough=True) as notifications:
self.preprint_branded.set_published(True, auth=Auth(self.user), save=True)
assert len(notifications['emits']) == 1
assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION
messages = get_mailhog_messages()
assert not messages['items']
with capture_notifications(passthrough=True) as notifications:
send_users_instant_digest_email.delay()
massages = get_mailhog_messages()
assert massages['count'] == len(notifications['emits'])
assert_emails(massages, notifications)
assert_emails(messages, notifications)

delete_mailhog_messages()
5 changes: 2 additions & 3 deletions api_tests/mailhog/provider/test_submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from osf.models import NotificationType

from osf.migrations import update_provider_auth_groups
from tests.utils import capture_notifications, get_mailhog_messages, delete_mailhog_messages
from tests.utils import capture_notifications, get_mailhog_messages, delete_mailhog_messages, assert_emails


@pytest.mark.django_db
Expand Down Expand Up @@ -85,8 +85,7 @@ def test_get_registration_actions(self, app, registration_actions_url, registrat
assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS
assert notifications['emits'][1]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS
messages = get_mailhog_messages()
assert messages['count'] == 1
assert messages['items'][0]['Content']['Headers']['To'][0] == registration.creator.username
assert_emails(messages, notifications)

delete_mailhog_messages()

Expand Down
7 changes: 2 additions & 5 deletions api_tests/notifications/test_notification_digest.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,15 @@ def add_notification_subscription(user, notification_type, frequency, subscribed
Create a NotificationSubscription for a user.
If the notification type corresponds to a subscribed_object, set subscribed_object to get the provider.
"""
from osf.models import NotificationSubscription, AbstractProvider
from osf.models import NotificationSubscription
kwargs = {
'user': user,
'notification_type': NotificationType.objects.get(name=notification_type),
'message_frequency': frequency,
}
if subscribed_object is not None:
kwargs['object_id'] = subscribed_object.id
if isinstance(subscribed_object, AbstractProvider):
kwargs['content_type'] = ContentType.objects.get_for_model(subscribed_object, for_concrete_model=False) if subscribed_object else None
else:
kwargs['content_type'] = ContentType.objects.get_for_model(subscribed_object) if subscribed_object else None
kwargs['content_type'] = ContentType.objects.get_for_model(subscribed_object)
if subscription is not None:
kwargs['object_id'] = subscription.id
kwargs['content_type'] = ContentType.objects.get_for_model(subscription)
Expand Down
189 changes: 189 additions & 0 deletions api_tests/notifications/test_notifications_cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import pytest
from osf.models import Notification, NotificationType, EmailTask, NotificationSubscription
from notifications.tasks import (
notifications_cleanup_task
)
from osf_tests.factories import AuthUserFactory
from website.settings import NOTIFICATIONS_CLEANUP_AGE
from django.utils import timezone
from datetime import timedelta

def create_notification(subscription, sent_date=None):
return Notification.objects.create(
subscription=subscription,
event_context={},
sent=sent_date
)

def create_email_task(user, created_date):
et = EmailTask.objects.create(
task_id=f'test-{created_date.timestamp()}',
user=user,
status='SUCCESS',
)
et.created_at = created_date
et.save()
return et

@pytest.mark.django_db
class TestNotificationCleanUpTask:

@pytest.fixture()
def user(self):
return AuthUserFactory()

@pytest.fixture()
def notification_type(self):
return NotificationType.objects.get_or_create(
name='Test Notification',
subject='Hello',
template='Sample Template',
)[0]

@pytest.fixture()
def subscription(self, user, notification_type):
return NotificationSubscription.objects.get_or_create(
user=user,
notification_type=notification_type,
message_frequency='daily',
)[0]

def test_dry_run_does_not_delete_records(self, user, subscription):
now = timezone.now()

old_notification = create_notification(
subscription,
sent_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)
old_email_task = create_email_task(
user,
created_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)

notifications_cleanup_task(dry_run=True)

assert Notification.objects.filter(id=old_notification.id).exists()
assert EmailTask.objects.filter(id=old_email_task.id).exists()

def test_deletes_old_notifications_and_email_tasks(self, user, subscription):
now = timezone.now()

old_notification = create_notification(
subscription,
sent_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)
new_notification = create_notification(
subscription,
sent_date=now - timedelta(days=10),
)

old_email_task = create_email_task(
user,
created_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)
new_email_task = create_email_task(
user,
created_date=now - timedelta(days=10),
)

notifications_cleanup_task()

assert not Notification.objects.filter(id=old_notification.id).exists()
assert Notification.objects.filter(id=new_notification.id).exists()

assert not EmailTask.objects.filter(id=old_email_task.id).exists()
assert EmailTask.objects.filter(id=new_email_task.id).exists()

def test_records_at_cutoff_are_not_deleted(self, user, subscription):
now = timezone.now()
cutoff = now - NOTIFICATIONS_CLEANUP_AGE + timedelta(hours=1)

notification = create_notification(
subscription,
sent_date=cutoff,
)
email_task = create_email_task(
user,
created_date=cutoff,
)

notifications_cleanup_task()

assert Notification.objects.filter(id=notification.id).exists()
assert EmailTask.objects.filter(id=email_task.id).exists()

def test_cleanup_when_only_notifications_exist(self, user, subscription):
now = timezone.now()

notification = create_notification(
subscription,
sent_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)

notifications_cleanup_task()

assert not Notification.objects.filter(id=notification.id).exists()

def test_cleanup_when_only_email_tasks_exist(self, user, subscription):
now = timezone.now()

email_task = create_email_task(
user,
created_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)

notifications_cleanup_task()

assert not EmailTask.objects.filter(id=email_task.id).exists()

def test_task_is_idempotent(self, user, subscription):
now = timezone.now()

create_notification(
subscription,
sent_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)
create_email_task(
user,
created_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)

notifications_cleanup_task()
notifications_cleanup_task()

assert Notification.objects.count() == 0
assert EmailTask.objects.count() == 0

def test_recent_records_are_not_deleted(self, user, subscription):
now = timezone.now()

create_notification(
subscription,
sent_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)
create_email_task(
user,
created_date=now - NOTIFICATIONS_CLEANUP_AGE - timedelta(days=1),
)
create_notification(
subscription,
sent_date=now,
)
create_email_task(
user,
created_date=now,
)

notifications_cleanup_task()

assert Notification.objects.count() == 1
assert EmailTask.objects.count() == 1

def test_not_sent_notifications_are_not_deleted(self, user, subscription):
create_notification(subscription)
create_notification(subscription)
create_notification(subscription)

notifications_cleanup_task()

assert Notification.objects.count() == 3
Loading
Loading