From 57b0bd1024b3969cea4f6ca40ebbee0c340e640f Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Tue, 20 May 2025 10:06:40 -0400 Subject: [PATCH 1/9] add new data model for notifications --- admin/notifications/views.py | 8 +- admin_tests/notifications/test_views.py | 19 +- api/subscriptions/permissions.py | 4 +- api/subscriptions/views.py | 11 +- .../views/test_subscriptions_detail.py | 4 +- .../views/test_subscriptions_list.py | 4 +- osf/email/__init__.py | 68 ++++ .../commands/add_notification_subscription.py | 9 +- ...ion_provider_notification_subscriptions.py | 4 +- ...ion_provider_notification_subscriptions.py | 4 +- .../0030_new_notifications_model.py | 104 +++++ osf/models/__init__.py | 8 +- osf/models/collection_submission.py | 4 +- osf/models/notification.py | 356 ++++++++++++++++++ osf/models/notifications.py | 7 +- osf/models/provider.py | 4 +- osf_tests/factories.py | 4 +- osf_tests/utils.py | 4 +- scripts/add_global_subscriptions.py | 6 +- ...cation_subscriptions_from_registrations.py | 2 +- tests/test_events.py | 28 +- tests/test_notifications.py | 157 ++++---- website/notifications/emails.py | 7 +- website/notifications/utils.py | 38 +- website/notifications/views.py | 9 +- website/reviews/listeners.py | 12 +- 26 files changed, 715 insertions(+), 170 deletions(-) create mode 100644 osf/email/__init__.py create mode 100644 osf/migrations/0030_new_notifications_model.py create mode 100644 osf/models/notification.py diff --git a/admin/notifications/views.py b/admin/notifications/views.py index 7a3a13a8df8..3546878e9af 100644 --- a/admin/notifications/views.py +++ b/admin/notifications/views.py @@ -1,17 +1,17 @@ -from osf.models.notifications import NotificationSubscription +from osf.models.notifications import NotificationSubscriptionLegacy from django.db.models import Count def delete_selected_notifications(selected_ids): - NotificationSubscription.objects.filter(id__in=selected_ids).delete() + NotificationSubscriptionLegacy.objects.filter(id__in=selected_ids).delete() def detect_duplicate_notifications(node_id=None): - query = NotificationSubscription.objects.values('_id').annotate(count=Count('_id')).filter(count__gt=1) + query = NotificationSubscriptionLegacy.objects.values('_id').annotate(count=Count('_id')).filter(count__gt=1) if node_id: query = query.filter(node_id=node_id) detailed_duplicates = [] for dup in query: - notifications = NotificationSubscription.objects.filter( + notifications = NotificationSubscriptionLegacy.objects.filter( _id=dup['_id'] ).order_by('created') diff --git a/admin_tests/notifications/test_views.py b/admin_tests/notifications/test_views.py index 08ad695edd1..42d182a77e5 100644 --- a/admin_tests/notifications/test_views.py +++ b/admin_tests/notifications/test_views.py @@ -1,10 +1,11 @@ import pytest from django.test import RequestFactory -from osf.models import OSFUser, NotificationSubscription, Node +from osf.models import OSFUser, Node from admin.notifications.views import ( delete_selected_notifications, detect_duplicate_notifications, ) +from osf.models.notifications import NotificationSubscriptionLegacy from tests.base import AdminTestCase pytestmark = pytest.mark.django_db @@ -18,19 +19,19 @@ def setUp(self): self.request_factory = RequestFactory() def test_delete_selected_notifications(self): - notification1 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') - notification2 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event2') - notification3 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event3') + notification1 = NotificationSubscriptionLegacy.objects.create(user=self.user, node=self.node, event_name='event1') + notification2 = NotificationSubscriptionLegacy.objects.create(user=self.user, node=self.node, event_name='event2') + notification3 = NotificationSubscriptionLegacy.objects.create(user=self.user, node=self.node, event_name='event3') delete_selected_notifications([notification1.id, notification2.id]) - assert not NotificationSubscription.objects.filter(id__in=[notification1.id, notification2.id]).exists() - assert NotificationSubscription.objects.filter(id=notification3.id).exists() + assert not NotificationSubscriptionLegacy.objects.filter(id__in=[notification1.id, notification2.id]).exists() + assert NotificationSubscriptionLegacy.objects.filter(id=notification3.id).exists() def test_detect_duplicate_notifications(self): - NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') - NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') - NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event2') + NotificationSubscriptionLegacy.objects.create(user=self.user, node=self.node, event_name='event1') + NotificationSubscriptionLegacy.objects.create(user=self.user, node=self.node, event_name='event1') + NotificationSubscriptionLegacy.objects.create(user=self.user, node=self.node, event_name='event2') duplicates = detect_duplicate_notifications() diff --git a/api/subscriptions/permissions.py b/api/subscriptions/permissions.py index 19dc7bcbd58..f0f3553ad6c 100644 --- a/api/subscriptions/permissions.py +++ b/api/subscriptions/permissions.py @@ -1,12 +1,12 @@ from rest_framework import permissions -from osf.models.notifications import NotificationSubscription +from osf.models.notifications import NotificationSubscriptionLegacy class IsSubscriptionOwner(permissions.BasePermission): def has_object_permission(self, request, view, obj): - assert isinstance(obj, NotificationSubscription), f'obj must be a NotificationSubscription; got {obj}' + assert isinstance(obj, NotificationSubscriptionLegacy), f'obj must be a NotificationSubscriptionLegacy; got {obj}' user_id = request.user.id return obj.none.filter(id=user_id).exists() \ or obj.email_transactional.filter(id=user_id).exists() \ diff --git a/api/subscriptions/views.py b/api/subscriptions/views.py index c1d7e833b49..a3c11a52aa8 100644 --- a/api/subscriptions/views.py +++ b/api/subscriptions/views.py @@ -22,6 +22,7 @@ RegistrationProvider, AbstractProvider, ) +from osf.models.notifications import NotificationSubscriptionLegacy class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): @@ -39,7 +40,7 @@ class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): def get_default_queryset(self): user = self.request.user - return NotificationSubscription.objects.filter( + return NotificationSubscriptionLegacy.objects.filter( Q(none=user) | Q(email_digest=user) | Q( @@ -54,7 +55,7 @@ def get_queryset(self): class AbstractProviderSubscriptionList(SubscriptionList): def get_default_queryset(self): user = self.request.user - return NotificationSubscription.objects.filter( + return NotificationSubscriptionLegacy.objects.filter( provider___id=self.kwargs['provider_id'], provider__type=self.provider_class._typedmodels_type, ).filter( @@ -80,7 +81,7 @@ class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView): def get_object(self): subscription_id = self.kwargs['subscription_id'] try: - obj = NotificationSubscription.objects.get(_id=subscription_id) + obj = NotificationSubscriptionLegacy.objects.get(_id=subscription_id) except ObjectDoesNotExist: raise NotFound self.check_object_permissions(self.request, obj) @@ -109,7 +110,7 @@ def get_object(self): if self.kwargs.get('provider_id'): provider = self.provider_class.objects.get(_id=self.kwargs.get('provider_id')) try: - obj = NotificationSubscription.objects.get( + obj = NotificationSubscriptionLegacy.objects.get( _id=subscription_id, provider_id=provider.id, ) @@ -117,7 +118,7 @@ def get_object(self): raise NotFound else: try: - obj = NotificationSubscription.objects.get( + obj = NotificationSubscriptionLegacy.objects.get( _id=subscription_id, provider__type=self.provider_class._typedmodels_type, ) diff --git a/api_tests/subscriptions/views/test_subscriptions_detail.py b/api_tests/subscriptions/views/test_subscriptions_detail.py index 2a8741fc173..f64c835ad10 100644 --- a/api_tests/subscriptions/views/test_subscriptions_detail.py +++ b/api_tests/subscriptions/views/test_subscriptions_detail.py @@ -1,7 +1,7 @@ import pytest from api.base.settings.defaults import API_BASE -from osf_tests.factories import AuthUserFactory, NotificationSubscriptionFactory +from osf_tests.factories import AuthUserFactory, NotificationSubscriptionLegacyFactory @pytest.mark.django_db @@ -17,7 +17,7 @@ def user_no_auth(self): @pytest.fixture() def global_user_notification(self, user): - notification = NotificationSubscriptionFactory(_id=f'{user._id}_global', user=user, event_name='global') + notification = NotificationSubscriptionLegacyFactory(_id=f'{user._id}_global', user=user, event_name='global') notification.add_user_to_subscription(user, 'email_transactional') return notification diff --git a/api_tests/subscriptions/views/test_subscriptions_list.py b/api_tests/subscriptions/views/test_subscriptions_list.py index f1131b1fa72..1eca735c456 100644 --- a/api_tests/subscriptions/views/test_subscriptions_list.py +++ b/api_tests/subscriptions/views/test_subscriptions_list.py @@ -1,7 +1,7 @@ import pytest from api.base.settings.defaults import API_BASE -from osf_tests.factories import AuthUserFactory, PreprintProviderFactory, ProjectFactory, NotificationSubscriptionFactory +from osf_tests.factories import AuthUserFactory, PreprintProviderFactory, ProjectFactory, NotificationSubscriptionLegacyFactory @pytest.mark.django_db @@ -23,7 +23,7 @@ def node(self, user): @pytest.fixture() def global_user_notification(self, user): - notification = NotificationSubscriptionFactory(_id=f'{user._id}_global', user=user, event_name='global') + notification = NotificationSubscriptionLegacyFactory(_id=f'{user._id}_global', user=user, event_name='global') notification.add_user_to_subscription(user, 'email_transactional') return notification diff --git a/osf/email/__init__.py b/osf/email/__init__.py new file mode 100644 index 00000000000..d8cc1d6de5a --- /dev/null +++ b/osf/email/__init__.py @@ -0,0 +1,68 @@ +import logging +import smtplib +from email.mime.text import MIMEText +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Mail +from website import settings + +def send_email_over_smtp(to_addr, notification_type, context): + """Send an email notification using SMTP. This is typically not used in productions as other 3rd party mail services + are preferred. This is to be used for tests and on staging environments and special situations. + + Args: + to_addr (str): The recipient's email address. + notification_type (str): The subject of the notification. + context (dict): The email content context. + """ + if not settings.MAIL_SERVER: + raise NotImplementedError('MAIL_SERVER is not set') + if not settings.MAIL_USERNAME and settings.MAIL_PASSWORD: + raise NotImplementedError('MAIL_USERNAME and MAIL_PASSWORD are required for STMP') + + msg = MIMEText( + notification_type.template.format(context), + 'html', + _charset='utf-8' + ) + msg['Subject'] = notification_type.email_subject_line_template.format(context=context) + + with smtplib.SMTP(settings.MAIL_SERVER) as server: + server.ehlo() + server.starttls() + server.ehlo() + server.login(settings.MAIL_USERNAME, settings.MAIL_PASSWORD) + server.sendmail( + settings.FROM_EMAIL, + [to_addr], + msg.as_string() + ) + + +def send_email_with_send_grid(to_addr, notification_type, context): + """Send an email notification using SendGrid. + + Args: + to_addr (str): The recipient's email address. + notification_type (str): The subject of the notification. + context (dict): The email content context. + """ + if not settings.SENDGRID_API_KEY: + raise NotImplementedError('SENDGRID_API_KEY is required for sendgrid notifications.') + + message = Mail( + from_email=settings.FROM_EMAIL, + to_emails=to_addr, + subject=notification_type, + html_content=context.get('message', '') + ) + + try: + sg = SendGridAPIClient(settings.SENDGRID_API_KEY) + response = sg.send(message) + if response.status_code not in (200, 201, 202): + logging.error(f'SendGrid response error: {response.status_code}, body: {response.body}') + response.raise_for_status() + logging.info(f'Notification email sent to {to_addr} for {notification_type}.') + except Exception as exc: + logging.error(f'Failed to send email notification to {to_addr}: {exc}') + raise exc diff --git a/osf/management/commands/add_notification_subscription.py b/osf/management/commands/add_notification_subscription.py index 7d9a404f37a..46c0a17ec30 100644 --- a/osf/management/commands/add_notification_subscription.py +++ b/osf/management/commands/add_notification_subscription.py @@ -5,6 +5,7 @@ import logging import django + django.setup() from django.core.management.base import BaseCommand @@ -20,9 +21,9 @@ def add_reviews_notification_setting(notification_type, state=None): if state: OSFUser = state.get_model('osf', 'OSFUser') - NotificationSubscription = state.get_model('osf', 'NotificationSubscription') + NotificationSubscriptionLegacy = state.get_model('osf', 'NotificationSubscriptionLegacy') else: - from osf.models import OSFUser, NotificationSubscription + from osf.models import OSFUser, NotificationSubscriptionLegacy active_users = OSFUser.objects.filter(date_confirmed__isnull=False).exclude(date_disabled__isnull=False).exclude(is_active=False).order_by('id') total_active_users = active_users.count() @@ -33,10 +34,10 @@ def add_reviews_notification_setting(notification_type, state=None): for user in active_users.iterator(): user_subscription_id = to_subscription_key(user._id, notification_type) - subscription = NotificationSubscription.load(user_subscription_id) + subscription = NotificationSubscriptionLegacy.load(user_subscription_id) if not subscription: logger.info(f'No {notification_type} subscription found for user {user._id}. Subscribing...') - subscription = NotificationSubscription(_id=user_subscription_id, owner=user, event_name=notification_type) + subscription = NotificationSubscriptionLegacy(_id=user_subscription_id, owner=user, event_name=notification_type) subscription.save() # Need to save in order to access m2m fields subscription.add_user_to_subscription(user, 'email_transactional') else: diff --git a/osf/management/commands/populate_collection_provider_notification_subscriptions.py b/osf/management/commands/populate_collection_provider_notification_subscriptions.py index 5713b08061b..c3a21eb8d20 100644 --- a/osf/management/commands/populate_collection_provider_notification_subscriptions.py +++ b/osf/management/commands/populate_collection_provider_notification_subscriptions.py @@ -1,7 +1,7 @@ import logging from django.core.management.base import BaseCommand -from osf.models import NotificationSubscription, CollectionProvider +from osf.models import NotificationSubscriptionLegacy, CollectionProvider logger = logging.getLogger(__file__) @@ -12,7 +12,7 @@ def populate_collection_provider_notification_subscriptions(): provider_moderators = provider.get_group('moderator').user_set.all() for subscription in provider.DEFAULT_SUBSCRIPTIONS: - instance, created = NotificationSubscription.objects.get_or_create( + instance, created = NotificationSubscriptionLegacy.objects.get_or_create( _id=f'{provider._id}_{subscription}', event_name=subscription, provider=provider diff --git a/osf/management/commands/populate_registration_provider_notification_subscriptions.py b/osf/management/commands/populate_registration_provider_notification_subscriptions.py index fe372fcbb80..db4b44acba5 100644 --- a/osf/management/commands/populate_registration_provider_notification_subscriptions.py +++ b/osf/management/commands/populate_registration_provider_notification_subscriptions.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Group from django.core.management.base import BaseCommand -from osf.models import NotificationSubscription, RegistrationProvider +from osf.models import RegistrationProvider, NotificationSubscriptionLegacy logger = logging.getLogger(__file__) @@ -17,7 +17,7 @@ def populate_registration_provider_notification_subscriptions(): continue for subscription in provider.DEFAULT_SUBSCRIPTIONS: - instance, created = NotificationSubscription.objects.get_or_create( + instance, created = NotificationSubscriptionLegacy.objects.get_or_create( _id=f'{provider._id}_{subscription}', event_name=subscription, provider=provider diff --git a/osf/migrations/0030_new_notifications_model.py b/osf/migrations/0030_new_notifications_model.py new file mode 100644 index 00000000000..ec044b08a07 --- /dev/null +++ b/osf/migrations/0030_new_notifications_model.py @@ -0,0 +1,104 @@ +import osf +from django.db import migrations, models +from django.conf import settings +import django_extensions.db.fields +import django.db.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0029_remove_abstractnode_keenio_read_key'), + ] + + operations = [ + migrations.RunSQL( + """ + DO $$ + DECLARE + idx record; + BEGIN + FOR idx IN + SELECT indexname + FROM pg_indexes + WHERE tablename = 'osf_notificationsubscription' + LOOP + EXECUTE format( + 'ALTER INDEX %I RENAME TO %I', + idx.indexname, + replace(idx.indexname, 'osf_notificationsubscription', 'osf_notificationsubscription_legacy') + ); + END LOOP; + END$$; + """ + ), + migrations.AlterModelTable( + name='NotificationSubscription', + table='osf_notificationsubscription_legacy', + ), + + migrations.RenameModel( + old_name='NotificationSubscription', + new_name='NotificationSubscriptionLegacy', + ), + migrations.CreateModel( + name='NotificationType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('notification_freq', models.CharField( + choices=[('none', 'None'), ('instantly', 'Instantly'), ('daily', 'Daily'), ('weekly', 'Weekly'), + ('monthly', 'Monthly')], default='instantly', max_length=32)), + ('template', models.TextField( + help_text='Template used to render the event_info. Supports Django template syntax.')), + ('object_content_type', models.ForeignKey(blank=True, + help_text='Content type for subscribed objects. Null means global event.', + null=True, on_delete=django.db.models.deletion.SET_NULL, + to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'Notification Type', + 'verbose_name_plural': 'Notification Types', + }, + ), + migrations.CreateModel( + name='NotificationSubscription', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', + django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', + django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('message_frequency', models.CharField(max_length=32)), + ('object_id', models.CharField(blank=True, max_length=255, null=True)), + ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + to='contenttypes.contenttype')), + ('notification_type', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='osf.notificationtype')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', + to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Notification Subscription', + 'verbose_name_plural': 'Notification Subscriptions', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_context', models.JSONField()), + ('sent', models.DateTimeField(blank=True, null=True)), + ('seen', models.DateTimeField(blank=True, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('subscription', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', + to='osf.notificationsubscription')), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + }, + ) + ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index 275fd148b6c..d3857e5df34 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -62,7 +62,12 @@ from .node_relation import NodeRelation from .nodelog import NodeLog from .notable_domain import NotableDomain, DomainReference -from .notifications import NotificationDigest, NotificationSubscription +from .notifications import NotificationDigest, NotificationSubscriptionLegacy +from .notification import ( + NotificationSubscription, + Notification, + NotificationType +) from .oauth import ( ApiOAuth2Application, ApiOAuth2PersonalToken, @@ -111,4 +116,3 @@ OSFUser, ) from .user_message import UserMessage - diff --git a/osf/models/collection_submission.py b/osf/models/collection_submission.py index 893533d85d1..56c5a64f659 100644 --- a/osf/models/collection_submission.py +++ b/osf/models/collection_submission.py @@ -132,10 +132,10 @@ def _notify_moderators_pending(self, event_data): 'allow_submissions': True, } - from .notifications import NotificationSubscription + from .notifications import NotificationSubscriptionLegacy from website.notifications.emails import store_emails - provider_subscription, created = NotificationSubscription.objects.get_or_create( + provider_subscription, created = NotificationSubscriptionLegacy.objects.get_or_create( _id=f'{self.collection.provider._id}_new_pending_submissions', provider=self.collection.provider ) diff --git a/osf/models/notification.py b/osf/models/notification.py new file mode 100644 index 00000000000..b95d5140ebc --- /dev/null +++ b/osf/models/notification.py @@ -0,0 +1,356 @@ +import logging + +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.template import Template, TemplateSyntaxError +from .base import BaseModel +from enum import Enum +from website import settings +from api.base import settings as api_settings +from osf import email + + +class FrequencyChoices(Enum): + NONE = 'none' + INSTANTLY = 'instantly' + DAILY = 'daily' + WEEKLY = 'weekly' + MONTHLY = 'monthly' + + @classmethod + def choices(cls): + return [(key.value, key.name.capitalize()) for key in cls] + + +class NotificationType(models.Model): + class Type(str, Enum): + # Desk notifications + DESK_REQUEST_EXPORT = 'desk_request_export' + DESK_REQUEST_DEACTIVATION = 'desk_request_deactivation' + DESK_OSF_SUPPORT_EMAIL = 'desk_osf_support_email' + DESK_REGISTRATION_BULK_UPLOAD_PRODUCT_OWNER = 'desk_registration_bulk_upload_product_owner' + DESK_USER_REGISTRATION_BULK_UPLOAD_UNEXPECTED_FAILURE = 'desk_user_registration_bulk_upload_unexpected_failure' + DESK_ARCHIVE_JOB_EXCEEDED = 'desk_archive_job_exceeded' + DESK_ARCHIVE_JOB_COPY_ERROR = 'desk_archive_job_copy_error' + DESK_ARCHIVE_JOB_FILE_NOT_FOUND = 'desk_archive_job_file_not_found' + DESK_ARCHIVE_JOB_UNCAUGHT_ERROR = 'desk_archive_job_uncaught_error' + + # User notifications + USER_PENDING_VERIFICATION = 'user_pending_verification' + USER_PENDING_VERIFICATION_REGISTERED = 'user_pending_verification_registered' + USER_STORAGE_CAP_EXCEEDED_ANNOUNCEMENT = 'user_storage_cap_exceeded_announcement' + USER_SPAM_BANNED = 'user_spam_banned' + USER_REQUEST_DEACTIVATION_COMPLETE = 'user_request_deactivation_complete' + USER_PRIMARY_EMAIL_CHANGED = 'user_primary_email_changed' + USER_INSTITUTION_DEACTIVATION = 'user_institution_deactivation' + USER_FORGOT_PASSWORD = 'user_forgot_password' + USER_FORGOT_PASSWORD_INSTITUTION = 'user_forgot_password_institution' + USER_REQUEST_EXPORT = 'user_request_export' + USER_CONTRIBUTOR_ADDED_OSF_PREPRINT = 'user_contributor_added_osf_preprint' + USER_CONTRIBUTOR_ADDED_DEFAULT = 'user_contributor_added_default' + USER_DUPLICATE_ACCOUNTS_OSF4I = 'user_duplicate_accounts_osf4i' + USER_EXTERNAL_LOGIN_LINK_SUCCESS = 'user_external_login_link_success' + USER_REGISTRATION_BULK_UPLOAD_FAILURE_ALL = 'user_registration_bulk_upload_failure_all' + USER_REGISTRATION_BULK_UPLOAD_SUCCESS_PARTIAL = 'user_registration_bulk_upload_success_partial' + USER_REGISTRATION_BULK_UPLOAD_SUCCESS_ALL = 'user_registration_bulk_upload_success_all' + USER_ADD_SSO_EMAIL_OSF4I = 'user_add_sso_email_osf4i' + USER_WELCOME_OSF4I = 'user_welcome_osf4i' + USER_ARCHIVE_JOB_EXCEEDED = 'user_archive_job_exceeded' + USER_ARCHIVE_JOB_COPY_ERROR = 'user_archive_job_copy_error' + USER_ARCHIVE_JOB_FILE_NOT_FOUND = 'user_archive_job_file_not_found' + USER_ARCHIVE_JOB_UNCAUGHT_ERROR = 'user_archive_job_uncaught_error' + USER_COMMENT_REPLIES = 'user_comment_replies' + USER_COMMENTS = 'user_comments' + USER_FILE_UPDATED = 'user_file_updated' + USER_COMMENT_MENTIONS = 'user_mentions' + USER_REVIEWS = 'user_reviews' + USER_PASSWORD_RESET = 'user_password_reset' + USER_CONTRIBUTOR_ADDED_DRAFT_REGISTRATION = 'user_contributor_added_draft_registration' + USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_CREATE = 'user_external_login_confirm_email_create' + USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_LINK = 'user_external_login_confirm_email_link' + USER_CONFIRM_MERGE = 'user_confirm_merge' + USER_CONFIRM_EMAIL = 'user_confirm_email' + USER_INITIAL_CONFIRM_EMAIL = 'user_initial_confirm_email' + USER_INVITE_DEFAULT = 'user_invite_default' + USER_PENDING_INVITE = 'user_pending_invite' + USER_FORWARD_INVITE = 'user_forward_invite' + USER_FORWARD_INVITE_REGISTERED = 'user_forward_invite_registered' + USER_INVITE_DRAFT_REGISTRATION = 'user_invite_draft_registration' + USER_INVITE_OSF_PREPRINT = 'user_invite_osf_preprint' + + # Node notifications + NODE_COMMENT = 'node_comments' + NODE_FILES_UPDATED = 'node_files_updated' + NODE_AFFILIATION_CHANGED = 'node_affiliation_changed' + NODE_REQUEST_ACCESS_SUBMITTED = 'node_access_request_submitted' + NODE_REQUEST_ACCESS_DENIED = 'node_request_access_denied' + NODE_FORK_COMPLETED = 'node_fork_completed' + NODE_FORK_FAILED = 'node_fork_failed' + NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST = 'node_request_institutional_access_request' + NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST = 'node_contributor_added_access_request' + NODE_PENDING_EMBARGO_ADMIN = 'node_pending_embargo_admin' + NODE_PENDING_EMBARGO_NON_ADMIN = 'node_pending_embargo_non_admin' + NODE_PENDING_RETRACTION_NON_ADMIN = 'node_pending_retraction_non_admin' + NODE_PENDING_RETRACTION_ADMIN = 'node_pending_retraction_admin' + NODE_PENDING_REGISTRATION_NON_ADMIN = 'node_pending_registration_non_admin' + NODE_PENDING_REGISTRATION_ADMIN = 'node_pending_registration_admin' + NODE_PENDING_EMBARGO_TERMINATION_NON_ADMIN = 'node_pending_embargo_termination_non_admin' + NODE_PENDING_EMBARGO_TERMINATION_ADMIN = 'node_pending_embargo_termination_admin' + + # Provider notifications + PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION = 'provider_reviews_submission_confirmation' + PROVIDER_REVIEWS_MODERATOR_SUBMISSION_CONFIRMATION = 'provider_reviews_moderator_submission_confirmation' + PROVIDER_REVIEWS_WITHDRAWAL_REQUESTED = 'preprint_request_withdrawal_requested' + PROVIDER_REVIEWS_REJECT_CONFIRMATION = 'provider_reviews_reject_confirmation' + PROVIDER_REVIEWS_ACCEPT_CONFIRMATION = 'provider_reviews_accept_confirmation' + PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION = 'provider_reviews_resubmission_confirmation' + PROVIDER_REVIEWS_COMMENT_EDITED = 'provider_reviews_comment_edited' + PROVIDER_CONTRIBUTOR_ADDED_PREPRINT = 'provider_contributor_added_preprint' + PROVIDER_CONFIRM_EMAIL_MODERATION = 'provider_confirm_email_moderation' + PROVIDER_MODERATOR_ADDED = 'provider_moderator_added' + PROVIDER_CONFIRM_EMAIL_PREPRINTS = 'provider_confirm_email_preprints' + PROVIDER_USER_INVITE_PREPRINT = 'provider_user_invite_preprint' + + # Preprint notifications + PREPRINT_REQUEST_WITHDRAWAL_APPROVED = 'preprint_request_withdrawal_approved' + PREPRINT_REQUEST_WITHDRAWAL_DECLINED = 'preprint_request_withdrawal_declined' + PREPRINT_CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF = 'preprint_contributor_added_preprint_node_from_osf' + + # Collections Submission notifications + NEW_PENDING_SUBMISSIONS = 'new_pending_submissions' + COLLECTION_SUBMISSION_REMOVED_ADMIN = 'collection_submission_removed_admin' + COLLECTION_SUBMISSION_REMOVED_MODERATOR = 'collection_submission_removed_moderator' + COLLECTION_SUBMISSION_REMOVED_PRIVATE = 'collection_submission_removed_private' + COLLECTION_SUBMISSION_SUBMITTED = 'collection_submission_submitted' + COLLECTION_SUBMISSION_ACCEPTED = 'collection_submission_accepted' + COLLECTION_SUBMISSION_REJECTED = 'collection_submission_rejected' + COLLECTION_SUBMISSION_CANCEL = 'collection_submission_cancel' + + # Schema Response notifications + SCHEMA_RESPONSE_REJECTED = 'schema_response_rejected' + SCHEMA_RESPONSE_APPROVED = 'schema_response_approved' + SCHEMA_RESPONSE_SUBMITTED = 'schema_response_submitted' + SCHEMA_RESPONSE_INITIATED = 'schema_response_initiated' + + REGISTRATION_BULK_UPLOAD_FAILURE_DUPLICATES = 'registration_bulk_upload_failure_duplicates' + + @classmethod + def user_types(cls): + return [member for member in cls if member.name.startswith('USER_')] + + @classmethod + def node_types(cls): + return [member for member in cls if member.name.startswith('NODE_')] + + @classmethod + def preprint_types(cls): + return [member for member in cls if member.name.startswith('PREPRINT_')] + + @classmethod + def provider_types(cls): + return [member for member in cls if member.name.startswith('PROVIDER_')] + + @classmethod + def schema_response_types(cls): + return [member for member in cls if member.name.startswith('SCHEMA_RESPONSE_')] + + @classmethod + def desk_types(cls): + return [member for member in cls if member.name.startswith('DESK_')] + + name: str = models.CharField(max_length=255, unique=True) + notification_freq: str = models.CharField( + max_length=32, + choices=FrequencyChoices.choices(), + default=FrequencyChoices.INSTANTLY.value, + ) + + object_content_type = models.ForeignKey( + ContentType, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text='Content type for subscribed objects. Null means global event.' + ) + + template: str = models.TextField( + help_text='Template used to render the event_info. Supports Django template syntax.' + ) + + def clean(self): + try: + Template(self.template) + except TemplateSyntaxError as exc: + raise ValidationError({'template': f'Invalid template: {exc}'}) + + def emit(self, user, subscribed_object=None, event_context=None): + """Emit a notification to a user by creating Notification and NotificationSubscription objects. + + Args: + user (OSFUser): The recipient of the notification. + subscribed_object (optional): The object the subscription is related to. + event_context (dict, optional): Context for rendering the notification template. + """ + subscription, created = NotificationSubscription.objects.get_or_create( + notification_type=self, + user=user, + content_type=ContentType.objects.get_for_model(subscribed_object) if subscribed_object else None, + object_id=subscribed_object.pk if subscribed_object else None, + defaults={'message_frequency': self.notification_freq}, + ) + if subscription.message_frequency == 'instantly': + Notification.objects.create( + subscription=subscription, + event_context=event_context + ).send() + + def add_user_to_subscription(self, user, *args, **kwargs): + """ + """ + provider = kwargs.pop('provider', None) + node = kwargs.pop('node', None) + data = {} + if subscribed_object := provider or node: + data = { + 'object_id': subscribed_object.id, + 'content_type_id': ContentType.objects.get_for_model(subscribed_object).id, + } + + notification, created = NotificationSubscription.objects.get_or_create( + user=user, + notification_type=self, + **data, + ) + return notification + + def remove_user_from_subscription(self, user): + """ + """ + notification, _ = NotificationSubscription.objects.update_or_create( + user=user, + notification_type=self, + defaults={'message_frequency': FrequencyChoices.NONE.value} + ) + + def __str__(self) -> str: + return self.name + + class Meta: + verbose_name = 'Notification Type' + verbose_name_plural = 'Notification Types' + + +class NotificationSubscription(BaseModel): + notification_type: NotificationType = models.ForeignKey( + NotificationType, + on_delete=models.CASCADE, + null=False + ) + user = models.ForeignKey('osf.OSFUser', on_delete=models.CASCADE, related_name='subscriptions') + message_frequency: str = models.CharField(max_length=32) + + content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) + object_id = models.CharField(max_length=255, null=True, blank=True) + subscribed_object = GenericForeignKey('content_type', 'object_id') + + def clean(self): + ct = self.notification_type.object_content_type + + if ct: + if self.content_type != ct: + raise ValidationError('Subscribed object must match type\'s content_type.') + if not self.object_id: + raise ValidationError('Subscribed object ID is required.') + else: + if self.content_type or self.object_id: + raise ValidationError('Global subscriptions must not have an object.') + + if self.message_frequency not in self.notification_type.notification_freq: + raise ValidationError(f'{self.message_frequency!r} is not allowed for {self.notification_type.name!r}.') + + def __str__(self) -> str: + return f'{self.user} subscribes to {self.notification_type.name} ({self.message_frequency})' + + class Meta: + verbose_name = 'Notification Subscription' + verbose_name_plural = 'Notification Subscriptions' + + def emit(self, user, subscribed_object=None, event_context=None): + """Emit a notification to a user by creating Notification and NotificationSubscription objects. + + Args: + user (OSFUser): The recipient of the notification. + subscribed_object (optional): The object the subscription is related to. + event_context (dict, optional): Context for rendering the notification template. + """ + if self.message_frequency == 'instantly': + Notification.objects.create( + subscription=self, + event_context=event_context + ).send() + else: + Notification.objects.create( + subscription=self, + event_context=event_context + ) + +class Notification(models.Model): + subscription = models.ForeignKey( + NotificationSubscription, + on_delete=models.CASCADE, + related_name='notifications' + ) + event_context: dict = models.JSONField() + sent = models.DateTimeField(null=True, blank=True) + seen = models.DateTimeField(null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) + + def send(self, protocol_type='email', recipient=None): + if not protocol_type == 'email': + raise NotImplementedError(f'Protocol type {protocol_type}. Email notifications are only implemented.') + + recipient_address = getattr(recipient, 'username', None) or self.subscription.user + + if protocol_type == 'email' and settings.DEV_MODE and settings.ENABLE_TEST_EMAIL: + email.send_email_over_smtp( + recipient_address, + self.subscription.notification_type, + self.event_context + ) + elif protocol_type == 'email' and settings.DEV_MODE: + if not api_settings.CI_ENV: + logging.info( + f"Attempting to send email in DEV_MODE with ENABLE_TEST_EMAIL false just logs:" + f"\nto={recipient_address}" + f"\ntype={self.subscription.notification_type.name}" + f"\ncontext={self.event_context}" + ) + elif protocol_type == 'email': + email.send_email_with_send_grid( + getattr(recipient, 'username', None) or self.subscription.user, + self.subscription.notification_type, + self.event_context + ) + else: + raise NotImplementedError(f'protocol `{protocol_type}` is not supported.') + + self.mark_sent() + + def mark_sent(self) -> None: + raise NotImplementedError('mark_sent must be implemented by subclasses.') + # self.sent = timezone.now() + # self.save(update_fields=['sent']) + + def mark_seen(self) -> None: + raise NotImplementedError('mark_seen must be implemented by subclasses.') + # self.seen = timezone.now() + # self.save(update_fields=['seen']) + + def __str__(self) -> str: + return f'Notification for {self.subscription.user} [{self.subscription.notification_type.name}]' + + class Meta: + verbose_name = 'Notification' + verbose_name_plural = 'Notifications' diff --git a/osf/models/notifications.py b/osf/models/notifications.py index 86be3424832..41ec120b4ee 100644 --- a/osf/models/notifications.py +++ b/osf/models/notifications.py @@ -1,15 +1,16 @@ from django.contrib.postgres.fields import ArrayField from django.db import models + +from website.notifications.constants import NOTIFICATION_TYPES from .node import Node from .user import OSFUser from .base import BaseModel, ObjectIDMixin from .validators import validate_subscription_type from osf.utils.fields import NonNaiveDateTimeField -from website.notifications.constants import NOTIFICATION_TYPES from website.util import api_v2_url -class NotificationSubscription(BaseModel): +class NotificationSubscriptionLegacy(BaseModel): primary_identifier_name = '_id' _id = models.CharField(max_length=100, db_index=True, unique=False) # pxyz_wiki_updated, uabc_comment_replies @@ -29,6 +30,7 @@ class NotificationSubscription(BaseModel): class Meta: # Both PreprintProvider and RegistrationProvider default instances use "osf" as their `_id` unique_together = ('_id', 'provider') + db_table = 'osf_notificationsubscription_legacy' @classmethod def load(cls, q): @@ -95,7 +97,6 @@ def remove_user_from_subscription(self, user, save=True): if save: self.save() - class NotificationDigest(ObjectIDMixin, BaseModel): user = models.ForeignKey('OSFUser', null=True, blank=True, on_delete=models.CASCADE) provider = models.ForeignKey('AbstractProvider', null=True, blank=True, on_delete=models.CASCADE) diff --git a/osf/models/provider.py b/osf/models/provider.py index 2ee920a77e5..b8dacc174bf 100644 --- a/osf/models/provider.py +++ b/osf/models/provider.py @@ -19,7 +19,7 @@ from .brand import Brand from .citation import CitationStyle from .licenses import NodeLicense -from .notifications import NotificationSubscription +from .notifications import NotificationSubscriptionLegacy from .storage import ProviderAssetFile from .subject import Subject from osf.utils.datetime_aware_jsonfield import DateTimeAwareJSONField @@ -464,7 +464,7 @@ def create_provider_auth_groups(sender, instance, created, **kwargs): def create_provider_notification_subscriptions(sender, instance, created, **kwargs): if created: for subscription in instance.DEFAULT_SUBSCRIPTIONS: - NotificationSubscription.objects.get_or_create( + NotificationSubscriptionLegacy.objects.get_or_create( _id=f'{instance._id}_{subscription}', event_name=subscription, provider=instance diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 7ad8885e1ad..bf636677284 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -1049,9 +1049,9 @@ def handle_callback(self, response): } -class NotificationSubscriptionFactory(DjangoModelFactory): +class NotificationSubscriptionLegacyFactory(DjangoModelFactory): class Meta: - model = models.NotificationSubscription + model = models.NotificationSubscriptionLegacy def make_node_lineage(): diff --git a/osf_tests/utils.py b/osf_tests/utils.py index a8364a15478..b3f3c92bc88 100644 --- a/osf_tests/utils.py +++ b/osf_tests/utils.py @@ -16,7 +16,7 @@ Sanction, RegistrationProvider, RegistrationSchema, - NotificationSubscription + NotificationSubscriptionLegacy ) from osf.utils.migrations import create_schema_blocks_for_atomic_schema @@ -229,7 +229,7 @@ def _ensure_subscriptions(provider): Avoid that. ''' for subscription in provider.DEFAULT_SUBSCRIPTIONS: - NotificationSubscription.objects.get_or_create( + NotificationSubscriptionLegacy.objects.get_or_create( _id=f'{provider._id}_{subscription}', event_name=subscription, provider=provider diff --git a/scripts/add_global_subscriptions.py b/scripts/add_global_subscriptions.py index b326c6f9f67..52746875d79 100644 --- a/scripts/add_global_subscriptions.py +++ b/scripts/add_global_subscriptions.py @@ -6,13 +6,13 @@ import logging import sys +from osf.models.notifications import NotificationSubscriptionLegacy from website.app import setup_django setup_django() from django.apps import apps from django.db import transaction from website.app import init_app -from osf.models import NotificationSubscription from website.notifications import constants from website.notifications.utils import to_subscription_key @@ -35,10 +35,10 @@ def add_global_subscriptions(dry=True): for user_event in user_events: user_event_id = to_subscription_key(user._id, user_event) - subscription = NotificationSubscription.load(user_event_id) + subscription = NotificationSubscriptionLegacy.load(user_event_id) if not subscription: logger.info(f'No {user_event} subscription found for user {user._id}. Subscribing...') - subscription = NotificationSubscription(_id=user_event_id, owner=user, event_name=user_event) + subscription = NotificationSubscriptionLegacy(_id=user_event_id, owner=user, event_name=user_event) subscription.save() # Need to save in order to access m2m fields subscription.add_user_to_subscription(user, notification_type) subscription.save() diff --git a/scripts/remove_notification_subscriptions_from_registrations.py b/scripts/remove_notification_subscriptions_from_registrations.py index 8984cb25b50..94b20a19a93 100644 --- a/scripts/remove_notification_subscriptions_from_registrations.py +++ b/scripts/remove_notification_subscriptions_from_registrations.py @@ -17,7 +17,7 @@ def remove_notification_subscriptions_from_registrations(dry_run=True): Registration = apps.get_model('osf.Registration') NotificationSubscription = apps.get_model('osf.NotificationSubscription') - notifications_to_delete = NotificationSubscription.objects.filter(node__type='osf.registration') + notifications_to_delete = NotificationSubscriptionLegacy.objects.filter(node__type='osf.registration') registrations_affected = Registration.objects.filter( id__in=notifications_to_delete.values_list( 'node_id', flat=True diff --git a/tests/test_events.py b/tests/test_events.py index 866bf6ec337..c9e30273b49 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -131,7 +131,7 @@ def setUp(self): self.user_2 = factories.AuthUserFactory() self.project = factories.ProjectFactory(creator=self.user_1) # subscription - self.sub = factories.NotificationSubscriptionFactory( + self.sub = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + 'file_updated', owner=self.project, event_name='file_updated', @@ -157,7 +157,7 @@ def setUp(self): self.user = factories.UserFactory() self.consolidate_auth = Auth(user=self.user) self.project = factories.ProjectFactory() - self.project_subscription = factories.NotificationSubscriptionFactory( + self.project_subscription = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_file_updated', owner=self.project, event_name='file_updated' @@ -184,7 +184,7 @@ def setUp(self): self.user = factories.UserFactory() self.consolidate_auth = Auth(user=self.user) self.project = factories.ProjectFactory() - self.project_subscription = factories.NotificationSubscriptionFactory( + self.project_subscription = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_file_updated', owner=self.project, event_name='file_updated' @@ -219,7 +219,7 @@ def setUp(self): self.user = factories.UserFactory() self.consolidate_auth = Auth(user=self.user) self.project = factories.ProjectFactory() - self.project_subscription = factories.NotificationSubscriptionFactory( + self.project_subscription = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_file_updated', owner=self.project, event_name='file_updated' @@ -249,7 +249,7 @@ def setUp(self): self.user_2 = factories.AuthUserFactory() self.project = factories.ProjectFactory(creator=self.user_1) # subscription - self.sub = factories.NotificationSubscriptionFactory( + self.sub = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + 'file_updated', owner=self.project, event_name='file_updated', @@ -303,21 +303,21 @@ def setUp(self): ) # Subscriptions # for parent node - self.sub = factories.NotificationSubscriptionFactory( + self.sub = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_file_updated', owner=self.project, event_name='file_updated' ) self.sub.save() # for private node - self.private_sub = factories.NotificationSubscriptionFactory( + self.private_sub = factories.NotificationSubscriptionLegacyFactory( _id=self.private_node._id + '_file_updated', owner=self.private_node, event_name='file_updated' ) self.private_sub.save() # for file subscription - self.file_sub = factories.NotificationSubscriptionFactory( + self.file_sub = factories.NotificationSubscriptionLegacyFactory( _id='{pid}_{wbid}_file_updated'.format( pid=self.project._id, wbid=self.event.waterbutler_id @@ -398,21 +398,21 @@ def setUp(self): ) # Subscriptions # for parent node - self.sub = factories.NotificationSubscriptionFactory( + self.sub = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_file_updated', owner=self.project, event_name='file_updated' ) self.sub.save() # for private node - self.private_sub = factories.NotificationSubscriptionFactory( + self.private_sub = factories.NotificationSubscriptionLegacyFactory( _id=self.private_node._id + '_file_updated', owner=self.private_node, event_name='file_updated' ) self.private_sub.save() # for file subscription - self.file_sub = factories.NotificationSubscriptionFactory( + self.file_sub = factories.NotificationSubscriptionLegacyFactory( _id='{pid}_{wbid}_file_updated'.format( pid=self.project._id, wbid=self.event.waterbutler_id @@ -480,21 +480,21 @@ def setUp(self): ) # Subscriptions # for parent node - self.sub = factories.NotificationSubscriptionFactory( + self.sub = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_file_updated', owner=self.project, event_name='file_updated' ) self.sub.save() # for private node - self.private_sub = factories.NotificationSubscriptionFactory( + self.private_sub = factories.NotificationSubscriptionLegacyFactory( _id=self.private_node._id + '_file_updated', owner=self.private_node, event_name='file_updated' ) self.private_sub.save() # for file subscription - self.file_sub = factories.NotificationSubscriptionFactory( + self.file_sub = factories.NotificationSubscriptionLegacyFactory( _id='{pid}_{wbid}_file_updated'.format( pid=self.project._id, wbid=self.event.waterbutler_id diff --git a/tests/test_notifications.py b/tests/test_notifications.py index b52190ca999..64ab0b1bb75 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -7,7 +7,14 @@ from django.utils import timezone from framework.auth import Auth -from osf.models import Comment, NotificationDigest, NotificationSubscription, Guid, OSFUser +from osf.models import ( + Comment, + NotificationDigest, + NotificationSubscription, + Guid, + OSFUser, + NotificationSubscriptionLegacy +) from website.notifications.tasks import get_users_emails, send_users_email, group_by_node, remove_notifications from website.notifications.exceptions import InvalidSubscriptionError @@ -123,19 +130,19 @@ def test_new_node_creator_is_not_subscribed(self): def test_new_project_creator_is_subscribed_with_global_settings(self): user = factories.UserFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comments', user=user, event_name='global_comments' ).add_user_to_subscription(user, 'email_digest') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_file_updated', user=user, event_name='global_file_updated' ).add_user_to_subscription(user, 'none') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_mentions', user=user, event_name='global_mentions' @@ -146,8 +153,8 @@ def test_new_project_creator_is_subscribed_with_global_settings(self): user_subscriptions = list(utils.get_all_user_subscriptions(user)) event_types = [sub.event_name for sub in user_subscriptions] - file_updated_subscription = NotificationSubscription.objects.get(_id=node._id + '_file_updated') - comments_subscription = NotificationSubscription.objects.get(_id=node._id + '_comments') + file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') + comments_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_comments') assert len(user_subscriptions) == 5 # subscribed to both node and user settings assert 'file_updated' in event_types @@ -163,25 +170,25 @@ def test_new_project_creator_is_subscribed_with_global_settings(self): def test_new_node_creator_is_not_subscribed_with_global_settings(self): user = factories.UserFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comments', user=user, event_name='global_comments' ).add_user_to_subscription(user, 'email_digest') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_file_updated', user=user, event_name='global_file_updated' ).add_user_to_subscription(user, 'none') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comment_replies', user=user, event_name='global_comment_replies' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_mentions', user=user, event_name='global_mentions' @@ -213,25 +220,25 @@ def test_subscribe_user_to_registration_notifications(self): def test_new_project_creator_is_subscribed_with_default_global_settings(self): user = factories.UserFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comments', user=user, event_name='global_comments' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_file_updated', user=user, event_name='global_file_updated' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comment_replies', user=user, event_name='global_comment_replies' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_mentions', user=user, event_name='global_mentions' @@ -242,8 +249,8 @@ def test_new_project_creator_is_subscribed_with_default_global_settings(self): user_subscriptions = list(utils.get_all_user_subscriptions(user)) event_types = [sub.event_name for sub in user_subscriptions] - file_updated_subscription = NotificationSubscription.objects.get(_id=node._id + '_file_updated') - comments_subscription = NotificationSubscription.objects.get(_id=node._id + '_comments') + file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') + comments_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_comments') assert len(user_subscriptions) == 6 # subscribed to both node and user settings assert 'file_updated' in event_types @@ -259,19 +266,19 @@ def test_new_fork_creator_is_subscribed_with_default_global_settings(self): user = factories.UserFactory() project = factories.ProjectFactory(creator=user) - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comments', user=user, event_name='global_comments' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_file_updated', user=user, event_name='global_file_updated' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_mentions', user=user, event_name='global_mentions' @@ -282,10 +289,10 @@ def test_new_fork_creator_is_subscribed_with_default_global_settings(self): user_subscriptions = list(utils.get_all_user_subscriptions(user)) event_types = [sub.event_name for sub in user_subscriptions] - node_file_updated_subscription = NotificationSubscription.objects.get(_id=node._id + '_file_updated') - node_comments_subscription = NotificationSubscription.objects.get(_id=node._id + '_comments') - project_file_updated_subscription = NotificationSubscription.objects.get(_id=project._id + '_file_updated') - project_comments_subscription = NotificationSubscription.objects.get(_id=project._id + '_comments') + node_file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') + node_comments_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_comments') + project_file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=project._id + '_file_updated') + project_comments_subscription = NotificationSubscriptionLegacy.objects.get(_id=project._id + '_comments') assert len(user_subscriptions) == 7 # subscribed to project, fork, and user settings assert 'file_updated' in event_types @@ -301,25 +308,25 @@ def test_new_fork_creator_is_subscribed_with_default_global_settings(self): def test_new_node_creator_is_not_subscribed_with_default_global_settings(self): user = factories.UserFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comments', user=user, event_name='global_comments' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_file_updated', user=user, event_name='global_file_updated' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_comment_replies', user=user, event_name='global_comment_replies' ).add_user_to_subscription(user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_' + 'global_mentions', user=user, event_name='global_mentions' @@ -353,13 +360,13 @@ def test_contributor_subscribed_when_added_to_component(self): user = factories.UserFactory() contributor = factories.UserFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=contributor._id + '_' + 'global_comments', user=contributor, event_name='global_comments' ).add_user_to_subscription(contributor, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=contributor._id + '_' + 'global_file_updated', user=contributor, event_name='global_file_updated' @@ -371,8 +378,8 @@ def test_contributor_subscribed_when_added_to_component(self): contributor_subscriptions = list(utils.get_all_user_subscriptions(contributor)) event_types = [sub.event_name for sub in contributor_subscriptions] - file_updated_subscription = NotificationSubscription.objects.get(_id=node._id + '_file_updated') - comments_subscription = NotificationSubscription.objects.get(_id=node._id + '_comments') + file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') + comments_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_comments') assert len(contributor_subscriptions) == 4 # subscribed to both node and user settings assert 'file_updated' in event_types @@ -416,7 +423,7 @@ def test_create_new_subscription(self): # check that subscription was created event_id = self.node._id + '_' + 'comments' - s = NotificationSubscription.objects.get(_id=event_id) + s = NotificationSubscriptionLegacy.objects.get(_id=event_id) # check that user was added to notification_type field assert payload['id'] == s.owner._id @@ -455,7 +462,7 @@ def test_adopt_parent_subscription_default(self): self.app.post(url, json=payload, auth=self.node.creator.auth) event_id = self.node._id + '_' + 'comments' # confirm subscription was created because parent had default subscription - s = NotificationSubscription.objects.filter(_id=event_id).count() + s = NotificationSubscriptionLegacy.objects.filter(_id=event_id).count() assert 0 == s def test_change_subscription_to_adopt_parent_subscription_removes_user(self): @@ -469,7 +476,7 @@ def test_change_subscription_to_adopt_parent_subscription_removes_user(self): # check that subscription was created event_id = self.node._id + '_' + 'comments' - s = NotificationSubscription.objects.get(_id=event_id) + s = NotificationSubscriptionLegacy.objects.get(_id=event_id) # change subscription to adopt_parent new_payload = { @@ -510,7 +517,7 @@ def setUp(self): self.project.add_contributor(contributor=self.contributor, permissions=permissions.READ) self.project.save() - self.subscription = NotificationSubscription.objects.get( + self.subscription = NotificationSubscriptionLegacy.objects.get( node=self.project, _id=self.project._id + '_comments' ) @@ -519,7 +526,7 @@ def setUp(self): self.node.add_contributor(contributor=self.project.creator, permissions=permissions.ADMIN) self.node.save() - self.node_subscription = NotificationSubscription.objects.get( + self.node_subscription = NotificationSubscriptionLegacy.objects.get( _id=self.node._id + '_comments', node=self.node ) @@ -560,10 +567,10 @@ def test_node_subscriptions_and_backrefs_removed_when_node_is_deleted(self): project = factories.ProjectFactory() component = factories.NodeFactory(parent=project, creator=project.creator) - s = NotificationSubscription.objects.filter(email_transactional=project.creator) + s = NotificationSubscriptionLegacy.objects.filter(email_transactional=project.creator) assert s.count() == 2 - s = NotificationSubscription.objects.filter(email_transactional=component.creator) + s = NotificationSubscriptionLegacy.objects.filter(email_transactional=component.creator) assert s.count() == 2 with capture_signals() as mock_signals: @@ -575,17 +582,17 @@ def test_node_subscriptions_and_backrefs_removed_when_node_is_deleted(self): assert component.is_deleted assert mock_signals.signals_sent() == {node_deleted} - s = NotificationSubscription.objects.filter(email_transactional=project.creator) + s = NotificationSubscriptionLegacy.objects.filter(email_transactional=project.creator) assert s.count() == 0 - s = NotificationSubscription.objects.filter(email_transactional=component.creator) + s = NotificationSubscriptionLegacy.objects.filter(email_transactional=component.creator) assert s.count() == 0 - with pytest.raises(NotificationSubscription.DoesNotExist): - NotificationSubscription.objects.get(node=project) + with pytest.raises(NotificationSubscriptionLegacy.DoesNotExist): + NotificationSubscriptionLegacy.objects.get(node=project) - with pytest.raises(NotificationSubscription.DoesNotExist): - NotificationSubscription.objects.get(node=component) + with pytest.raises(NotificationSubscriptionLegacy.DoesNotExist): + NotificationSubscriptionLegacy.objects.get(node=component) def list_or_dict(data): @@ -671,7 +678,7 @@ def setUp(self): self.user = factories.UserFactory() self.project = factories.ProjectFactory(creator=self.user) - self.project_subscription = NotificationSubscription.objects.get( + self.project_subscription = NotificationSubscriptionLegacy.objects.get( node=self.project, _id=self.project._id + '_comments', event_name='comments' @@ -682,7 +689,7 @@ def setUp(self): self.node = factories.NodeFactory(parent=self.project, creator=self.user) - self.node_comments_subscription = factories.NotificationSubscriptionFactory( + self.node_comments_subscription = factories.NotificationSubscriptionLegacyFactory( _id=self.node._id + '_' + 'comments', node=self.node, event_name='comments' @@ -691,19 +698,19 @@ def setUp(self): self.node_comments_subscription.email_transactional.add(self.user) self.node_comments_subscription.save() - self.node_subscription = list(NotificationSubscription.objects.filter(node=self.node)) + self.node_subscription = list(NotificationSubscriptionLegacy.objects.filter(node=self.node)) - self.user_subscription = [factories.NotificationSubscriptionFactory( + self.user_subscription = [factories.NotificationSubscriptionLegacyFactory( _id=self.user._id + '_' + 'comment_replies', user=self.user, event_name='comment_replies' ), - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=self.user._id + '_' + 'global_comment', user=self.user, event_name='global_comment' ), - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=self.user._id + '_' + 'global_file_updated', user=self.user, event_name='global_file_updated' @@ -770,7 +777,7 @@ def test_get_configured_project_ids_excludes_node_with_project_category(self): def test_get_configured_project_ids_includes_top_level_private_projects_if_subscriptions_on_node(self): private_project = factories.ProjectFactory() node = factories.NodeFactory(parent=private_project) - node_comments_subscription = factories.NotificationSubscriptionFactory( + node_comments_subscription = factories.NotificationSubscriptionLegacyFactory( _id=node._id + '_' + 'comments', node=node, event_name='comments' @@ -903,7 +910,7 @@ def test_format_data_user_subscriptions_includes_private_parent_if_configured_ch private_project = factories.ProjectFactory() node = factories.NodeFactory(parent=private_project) - node_comments_subscription = factories.NotificationSubscriptionFactory( + node_comments_subscription = factories.NotificationSubscriptionLegacyFactory( _id=node._id + '_' + 'comments', node=node, event_name='comments' @@ -934,7 +941,7 @@ def test_format_data_user_subscriptions_if_children_points_to_parent(self): private_project = factories.ProjectFactory(creator=self.user) node = factories.NodeFactory(parent=private_project, creator=self.user) node.save() - node_comments_subscription = factories.NotificationSubscriptionFactory( + node_comments_subscription = factories.NotificationSubscriptionLegacyFactory( _id=node._id + '_' + 'comments', node=node, event_name='comments' @@ -1170,19 +1177,19 @@ def setUp(self): self.base_project.add_contributor(self.user_3, permissions=permissions.WRITE) self.shared_node.add_contributor(self.user_3, permissions=permissions.WRITE) # Setting basic subscriptions - self.base_sub = factories.NotificationSubscriptionFactory( + self.base_sub = factories.NotificationSubscriptionLegacyFactory( _id=self.base_project._id + '_file_updated', node=self.base_project, event_name='file_updated' ) self.base_sub.save() - self.shared_sub = factories.NotificationSubscriptionFactory( + self.shared_sub = factories.NotificationSubscriptionLegacyFactory( _id=self.shared_node._id + '_file_updated', node=self.shared_node, event_name='file_updated' ) self.shared_sub.save() - self.private_sub = factories.NotificationSubscriptionFactory( + self.private_sub = factories.NotificationSubscriptionLegacyFactory( _id=self.private_node._id + '_file_updated', node=self.private_node, event_name='file_updated' @@ -1196,7 +1203,7 @@ def test_no_subscription(self): def test_no_subscribers(self): node = factories.NodeFactory() - node_sub = factories.NotificationSubscriptionFactory( + node_sub = factories.NotificationSubscriptionLegacyFactory( _id=node._id + '_file_updated', node=node, event_name='file_updated' @@ -1260,7 +1267,7 @@ def test_several_nodes_deep_precedence(self): node2 = factories.NodeFactory(parent=self.shared_node) node3 = factories.NodeFactory(parent=node2) node4 = factories.NodeFactory(parent=node3) - node4_subscription = factories.NotificationSubscriptionFactory( + node4_subscription = factories.NotificationSubscriptionLegacyFactory( _id=node4._id + '_file_updated', node=node4, event_name='file_updated' @@ -1284,14 +1291,14 @@ def setUp(self): self.user_4 = factories.AuthUserFactory() self.project = factories.ProjectFactory(creator=self.user_1) self.private_node = factories.NodeFactory(parent=self.project, is_public=False, creator=self.user_1) - self.sub = factories.NotificationSubscriptionFactory( + self.sub = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_file_updated', node=self.project, event_name='file_updated' ) self.sub.email_transactional.add(self.user_1) self.sub.save() - self.file_sub = factories.NotificationSubscriptionFactory( + self.file_sub = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_xyz42_file_updated', node=self.project, event_name='xyz42_file_updated' @@ -1407,7 +1414,7 @@ def setUp(self): super().setUp() self.user = factories.AuthUserFactory() self.project = factories.ProjectFactory() - self.project_subscription = factories.NotificationSubscriptionFactory( + self.project_subscription = factories.NotificationSubscriptionLegacyFactory( _id=self.project._id + '_' + 'comments', node=self.project, event_name='comments' @@ -1417,13 +1424,13 @@ def setUp(self): self.project_subscription.save() self.node = factories.NodeFactory(parent=self.project) - self.node_subscription = factories.NotificationSubscriptionFactory( + self.node_subscription = factories.NotificationSubscriptionLegacyFactory( _id=self.node._id + '_comments', node=self.node, event_name='comments' ) self.node_subscription.save() - self.user_subscription = factories.NotificationSubscriptionFactory( + self.user_subscription = factories.NotificationSubscriptionLegacyFactory( _id=self.user._id + '_' + 'global_comment_replies', node=self.node, event_name='global_comment_replies' @@ -1441,7 +1448,7 @@ def test_notify_no_subscription(self, mock_store): @mock.patch('website.notifications.emails.store_emails') def test_notify_no_subscribers(self, mock_store): node = factories.NodeFactory() - node_subscription = factories.NotificationSubscriptionFactory( + node_subscription = factories.NotificationSubscriptionLegacyFactory( _id=node._id + '_comments', node=node, event_name='comments' @@ -1469,7 +1476,7 @@ def test_notify_does_not_send_to_exclude(self, mock_store): def test_notify_does_not_send_to_users_subscribed_to_none(self, mock_store): node = factories.NodeFactory() user = factories.UserFactory() - node_subscription = factories.NotificationSubscriptionFactory( + node_subscription = factories.NotificationSubscriptionLegacyFactory( _id=node._id + '_comments', node=node, event_name='comments' @@ -1485,7 +1492,7 @@ def test_notify_does_not_send_to_users_subscribed_to_none(self, mock_store): def test_notify_mentions_does_not_send_to_mentioned_users_subscribed_to_none(self, mock_store): node = factories.NodeFactory() user = factories.UserFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_global_mentions', node=self.node, event_name='global_mentions' @@ -1498,7 +1505,7 @@ def test_notify_mentions_does_not_send_to_mentioned_users_subscribed_to_none(sel @mock.patch('website.notifications.emails.store_emails') def test_notify_mentions_does_send_to_mentioned_users(self, mock_store): user = factories.UserFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_global_mentions', node=self.node, event_name='global_mentions' @@ -1572,7 +1579,7 @@ def test_check_node_one(self): def test_check_user_comment_reply_subscription_if_email_not_sent_to_target_user(self, mock_notify): # user subscribed to comment replies user = factories.UserFactory() - user_subscription = factories.NotificationSubscriptionFactory( + user_subscription = factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_comments', user=user, event_name='comment_replies' @@ -1603,7 +1610,7 @@ def test_check_user_comment_reply_subscription_if_email_not_sent_to_target_user( def test_check_user_comment_reply_only_calls_once(self, mock_notify): # user subscribed to comment replies user = factories.UserFactory() - user_subscription = factories.NotificationSubscriptionFactory( + user_subscription = factories.NotificationSubscriptionLegacyFactory( _id=user._id + '_comments', user=user, event_name='comment_replies' @@ -1885,19 +1892,19 @@ def setUp(self): 'provider_support_email': settings.OSF_SUPPORT_EMAIL, } self.action = factories.ReviewActionFactory() - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=self.user._id + '_' + 'global_comments', user=self.user, event_name='global_comments' ).add_user_to_subscription(self.user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=self.user._id + '_' + 'global_file_updated', user=self.user, event_name='global_file_updated' ).add_user_to_subscription(self.user, 'email_transactional') - factories.NotificationSubscriptionFactory( + factories.NotificationSubscriptionLegacyFactory( _id=self.user._id + '_' + 'global_reviews', user=self.user, event_name='global_reviews' @@ -1956,7 +1963,7 @@ def setUp(self): } self.action = factories.ReviewActionFactory() - self.subscription = NotificationSubscription.load(self.provider._id+'_new_pending_submissions') + self.subscription = NotificationSubscriptionLegacy.load(self.provider._id+'_new_pending_submissions') self.subscription.add_user_to_subscription(self.moderator_transacitonal, 'email_transactional') self.subscription.add_user_to_subscription(self.moderator_digest, 'email_digest') @@ -1971,7 +1978,7 @@ def test_reviews_submit_notification(self, mock_store): self.context_info_submission['profile_image_url'] = get_profile_image_url(self.context_info_submission['referrer']) self.context_info_submission['reviews_submission_url'] = f'{settings.DOMAIN}reviews/preprints/{provider._id}/{preprint._id}' listeners.reviews_submit_notification_moderators(self, time_now, self.context_info_submission) - subscription = NotificationSubscription.load(self.provider._id + '_new_pending_submissions') + subscription = NotificationSubscriptionLegacy.load(self.provider._id + '_new_pending_submissions') digest_subscriber_ids = list(subscription.email_digest.all().values_list('guids___id', flat=True)) instant_subscriber_ids = list(subscription.email_transactional.all().values_list('guids___id', flat=True)) @@ -2009,7 +2016,7 @@ def test_reviews_request_notification(self, mock_store): self.context_info_request[ 'reviewable']._id) listeners.reviews_withdrawal_requests_notification(self, time_now, self.context_info_request) - subscription = NotificationSubscription.load(self.provider._id + '_new_pending_submissions') + subscription = NotificationSubscriptionLegacy.load(self.provider._id + '_new_pending_submissions') digest_subscriber_ids = subscription.email_digest.all().values_list('guids___id', flat=True) instant_subscriber_ids = subscription.email_transactional.all().values_list('guids___id', flat=True) mock_store.assert_any_call(QuerySetMatcher(digest_subscriber_ids), diff --git a/website/notifications/emails.py b/website/notifications/emails.py index d26d43351d5..56f513920af 100644 --- a/website/notifications/emails.py +++ b/website/notifications/emails.py @@ -2,7 +2,8 @@ from babel import dates, core, Locale -from osf.models import AbstractNode, NotificationDigest, NotificationSubscription +from osf.models import AbstractNode, NotificationSubscriptionLegacy +from osf.models.notifications import NotificationDigest from osf.utils.permissions import ADMIN, READ from website import mails from website.notifications import constants @@ -159,7 +160,7 @@ def check_node(node, event): """Return subscription for a particular node and event.""" node_subscriptions = {key: [] for key in constants.NOTIFICATION_TYPES} if node: - subscription = NotificationSubscription.load(utils.to_subscription_key(node._id, event)) + subscription = NotificationSubscriptionLegacy.load(utils.to_subscription_key(node._id, event)) for notification_type in node_subscriptions: users = getattr(subscription, notification_type, []) if users: @@ -172,7 +173,7 @@ def check_node(node, event): def get_user_subscriptions(user, event): if user.is_disabled: return {} - user_subscription = NotificationSubscription.load(utils.to_subscription_key(user._id, event)) + user_subscription = NotificationSubscriptionLegacy.load(utils.to_subscription_key(user._id, event)) if user_subscription: return {key: list(getattr(user_subscription, key).all().values_list('guids___id', flat=True)) for key in constants.NOTIFICATION_TYPES} else: diff --git a/website/notifications/utils.py b/website/notifications/utils.py index af8275ab5fb..c2b229295d4 100644 --- a/website/notifications/utils.py +++ b/website/notifications/utils.py @@ -91,10 +91,10 @@ def remove_supplemental_node(node): @app.task(max_retries=5, default_retry_delay=60) def remove_subscription_task(node_id): AbstractNode = apps.get_model('osf.AbstractNode') - NotificationSubscription = apps.get_model('osf.NotificationSubscription') + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') node = AbstractNode.load(node_id) - NotificationSubscription.objects.filter(node=node).delete() + NotificationSubscriptionLegacy.objects.filter(node=node).delete() parent = node.parent_node if parent and parent.child_node_subscriptions: @@ -144,12 +144,12 @@ def users_to_remove(source_event, source_node, new_node): :param new_node: Node instance where a sub or new sub will be. :return: Dict of notification type lists with user_ids """ - NotificationSubscription = apps.get_model('osf.NotificationSubscription') + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') removed_users = {key: [] for key in constants.NOTIFICATION_TYPES} if source_node == new_node: return removed_users - old_sub = NotificationSubscription.load(to_subscription_key(source_node._id, source_event)) - old_node_sub = NotificationSubscription.load(to_subscription_key(source_node._id, + old_sub = NotificationSubscriptionLegacy.load(to_subscription_key(source_node._id, source_event)) + old_node_sub = NotificationSubscriptionLegacy.load(to_subscription_key(source_node._id, '_'.join(source_event.split('_')[-2:]))) if not old_sub and not old_node_sub: return removed_users @@ -172,11 +172,11 @@ def move_subscription(remove_users, source_event, source_node, new_event, new_no :param new_node: Instance of Node :return: Returns a NOTIFICATION_TYPES list of removed users without permissions """ - NotificationSubscription = apps.get_model('osf.NotificationSubscription') + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') OSFUser = apps.get_model('osf.OSFUser') if source_node == new_node: return - old_sub = NotificationSubscription.load(to_subscription_key(source_node._id, source_event)) + old_sub = NotificationSubscriptionLegacy.load(to_subscription_key(source_node._id, source_event)) if not old_sub: return elif old_sub: @@ -237,8 +237,8 @@ def check_project_subscriptions_are_all_none(user, node): def get_all_user_subscriptions(user, extra=None): """ Get all Subscription objects that the user is subscribed to""" - NotificationSubscription = apps.get_model('osf.NotificationSubscription') - queryset = NotificationSubscription.objects.filter( + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') + queryset = NotificationSubscriptionLegacy.objects.filter( Q(none=user.pk) | Q(email_digest=user.pk) | Q(email_transactional=user.pk) @@ -392,14 +392,14 @@ def get_parent_notification_type(node, event, user): :return: str notification type (e.g. 'email_transactional') """ AbstractNode = apps.get_model('osf.AbstractNode') - NotificationSubscription = apps.get_model('osf.NotificationSubscription') + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') if node and isinstance(node, AbstractNode) and node.parent_node and node.parent_node.has_permission(user, READ): parent = node.parent_node key = to_subscription_key(parent._id, event) try: - subscription = NotificationSubscription.objects.get(_id=key) - except NotificationSubscription.DoesNotExist: + subscription = NotificationSubscriptionLegacy.objects.get(_id=key) + except NotificationSubscriptionLegacy.DoesNotExist: return get_parent_notification_type(parent, event, user) for notification_type in constants.NOTIFICATION_TYPES: @@ -429,19 +429,19 @@ def check_if_all_global_subscriptions_are_none(user): # This function predates comment mentions, which is a global_ notification that cannot be disabled # Therefore, an actual check would never return True. # If this changes, an optimized query would look something like: - # not NotificationSubscription.objects.filter(Q(event_name__startswith='global_') & (Q(email_digest=user.pk)|Q(email_transactional=user.pk))).exists() + # not NotificationSubscriptionLegacy.objects.filter(Q(event_name__startswith='global_') & (Q(email_digest=user.pk)|Q(email_transactional=user.pk))).exists() return False def subscribe_user_to_global_notifications(user): - NotificationSubscription = apps.get_model('osf.NotificationSubscription') + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') notification_type = 'email_transactional' user_events = constants.USER_SUBSCRIPTIONS_AVAILABLE for user_event in user_events: user_event_id = to_subscription_key(user._id, user_event) # get_or_create saves on creation - subscription, created = NotificationSubscription.objects.get_or_create(_id=user_event_id, user=user, event_name=user_event) + subscription, created = NotificationSubscriptionLegacy.objects.get_or_create(_id=user_event_id, user=user, event_name=user_event) subscription.add_user_to_subscription(user, notification_type) subscription.save() @@ -450,7 +450,7 @@ def subscribe_user_to_notifications(node, user): """ Update the notification settings for the creator or contributors :param user: User to subscribe to notifications """ - NotificationSubscription = apps.get_model('osf.NotificationSubscription') + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') Preprint = apps.get_model('osf.Preprint') DraftRegistration = apps.get_model('osf.DraftRegistration') if isinstance(node, Preprint): @@ -476,16 +476,16 @@ def subscribe_user_to_notifications(node, user): for event in events: event_id = to_subscription_key(target_id, event) global_event_id = to_subscription_key(user._id, 'global_' + event) - global_subscription = NotificationSubscription.load(global_event_id) + global_subscription = NotificationSubscriptionLegacy.load(global_event_id) - subscription = NotificationSubscription.load(event_id) + subscription = NotificationSubscriptionLegacy.load(event_id) # If no subscription for component and creator is the user, do not create subscription # If no subscription exists for the component, this means that it should adopt its # parent's settings if not (node and node.parent_node and not subscription and node.creator == user): if not subscription: - subscription = NotificationSubscription(_id=event_id, owner=node, event_name=event) + subscription = NotificationSubscriptionLegacy(_id=event_id, owner=node, event_name=event) # Need to save here in order to access m2m fields subscription.save() if global_subscription: diff --git a/website/notifications/views.py b/website/notifications/views.py index 8ca4775367d..1cbb62ee08d 100644 --- a/website/notifications/views.py +++ b/website/notifications/views.py @@ -6,7 +6,8 @@ from framework.auth.decorators import must_be_logged_in from framework.exceptions import HTTPError -from osf.models import AbstractNode, NotificationSubscription, Registration +from osf.models import AbstractNode, Registration +from osf.models.notifications import NotificationSubscriptionLegacy from osf.utils.permissions import READ from website.notifications import utils from website.notifications.constants import NOTIFICATION_TYPES @@ -95,17 +96,17 @@ def configure_subscription(auth): raise HTTPError(http_status.HTTP_400_BAD_REQUEST) # If adopt_parent make sure that this subscription is None for the current User - subscription = NotificationSubscription.load(event_id) + subscription = NotificationSubscriptionLegacy.load(event_id) if not subscription: return {} # We're done here subscription.remove_user_from_subscription(user) return {} - subscription = NotificationSubscription.load(event_id) + subscription = NotificationSubscriptionLegacy.load(event_id) if not subscription: - subscription = NotificationSubscription(_id=event_id, owner=owner, event_name=event) + subscription = NotificationSubscriptionLegacy(_id=event_id, owner=owner, event_name=event) subscription.save() if node and node._id not in user.notifications_configured: diff --git a/website/reviews/listeners.py b/website/reviews/listeners.py index 27a15c2c337..d6f3471dac7 100644 --- a/website/reviews/listeners.py +++ b/website/reviews/listeners.py @@ -71,7 +71,7 @@ def reviews_submit_notification_moderators(self, timestamp, context): Handle email notifications to notify moderators of new submissions or resubmission. """ # imports moved here to avoid AppRegistryNotReady error - from osf.models import NotificationSubscription + from osf.models import NotificationSubscriptionLegacy from website.profile.utils import get_profile_image_url from website.notifications.emails import store_emails @@ -103,7 +103,7 @@ def reviews_submit_notification_moderators(self, timestamp, context): context['message'] = f'submitted "{resource.title}".' # Get NotificationSubscription instance, which contains reference to all subscribers - provider_subscription, created = NotificationSubscription.objects.get_or_create( + provider_subscription, created = NotificationSubscriptionLegacy.objects.get_or_create( _id=f'{provider._id}_new_pending_submissions', provider=provider ) @@ -138,7 +138,7 @@ def reviews_submit_notification_moderators(self, timestamp, context): @reviews_signals.reviews_withdraw_requests_notification_moderators.connect def reviews_withdraw_requests_notification_moderators(self, timestamp, context): # imports moved here to avoid AppRegistryNotReady error - from osf.models import NotificationSubscription + from osf.models import NotificationSubscriptionLegacy from website.profile.utils import get_profile_image_url from website.notifications.emails import store_emails @@ -146,7 +146,7 @@ def reviews_withdraw_requests_notification_moderators(self, timestamp, context): provider = resource.provider # Get NotificationSubscription instance, which contains reference to all subscribers - provider_subscription, created = NotificationSubscription.objects.get_or_create( + provider_subscription, created = NotificationSubscriptionLegacy.objects.get_or_create( _id=f'{provider._id}_new_pending_withdraw_requests', provider=provider ) @@ -191,13 +191,13 @@ def reviews_withdraw_requests_notification_moderators(self, timestamp, context): @reviews_signals.reviews_email_withdrawal_requests.connect def reviews_withdrawal_requests_notification(self, timestamp, context): # imports moved here to avoid AppRegistryNotReady error - from osf.models import NotificationSubscription + from osf.models import NotificationSubscriptionLegacy from website.notifications.emails import store_emails from website.profile.utils import get_profile_image_url from website import settings # Get NotificationSubscription instance, which contains reference to all subscribers - provider_subscription = NotificationSubscription.load( + provider_subscription = NotificationSubscriptionLegacy.load( '{}_new_pending_submissions'.format(context['reviewable'].provider._id)) preprint = context['reviewable'] preprint_word = preprint.provider.preprint_word From 69231e9f13926d41d52af0654a64bd2779d237cf Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Tue, 27 May 2025 13:52:43 -0400 Subject: [PATCH 2/9] add new notificationsubscription class to views --- api/subscriptions/fields.py | 12 ++ api/subscriptions/permissions.py | 9 +- api/subscriptions/serializers.py | 51 +++----- api/subscriptions/views.py | 66 ++-------- .../views/test_subscriptions_detail.py | 115 ++++++++++++------ osf/models/notification.py | 6 + osf_tests/factories.py | 11 ++ 7 files changed, 144 insertions(+), 126 deletions(-) create mode 100644 api/subscriptions/fields.py diff --git a/api/subscriptions/fields.py b/api/subscriptions/fields.py new file mode 100644 index 00000000000..c26ffaf5d4e --- /dev/null +++ b/api/subscriptions/fields.py @@ -0,0 +1,12 @@ +from rest_framework import serializers as ser +from osf.models import NotificationSubscription + +class FrequencyField(ser.ChoiceField): + def __init__(self, **kwargs): + super().__init__(choices=['none', 'instantly', 'daily', 'weekly', 'monthly'], **kwargs) + + def to_representation(self, obj: NotificationSubscription): + return obj.message_frequency + + def to_internal_value(self, freq): + return super().to_internal_value(freq) diff --git a/api/subscriptions/permissions.py b/api/subscriptions/permissions.py index f0f3553ad6c..a07eae6e81d 100644 --- a/api/subscriptions/permissions.py +++ b/api/subscriptions/permissions.py @@ -1,13 +1,10 @@ from rest_framework import permissions -from osf.models.notifications import NotificationSubscriptionLegacy +from osf.models.notification import NotificationSubscription class IsSubscriptionOwner(permissions.BasePermission): def has_object_permission(self, request, view, obj): - assert isinstance(obj, NotificationSubscriptionLegacy), f'obj must be a NotificationSubscriptionLegacy; got {obj}' - user_id = request.user.id - return obj.none.filter(id=user_id).exists() \ - or obj.email_transactional.filter(id=user_id).exists() \ - or obj.email_digest.filter(id=user_id).exists() + assert isinstance(obj, NotificationSubscription), f'obj must be a NotificationSubscription; got {obj}' + return obj.user == request.user diff --git a/api/subscriptions/serializers.py b/api/subscriptions/serializers.py index da7aadbb1a4..2bb1041d227 100644 --- a/api/subscriptions/serializers.py +++ b/api/subscriptions/serializers.py @@ -1,58 +1,43 @@ +from django.contrib.contenttypes.models import ContentType from rest_framework import serializers as ser -from rest_framework.exceptions import ValidationError from api.nodes.serializers import RegistrationProviderRelationshipField from api.collections_providers.fields import CollectionProviderRelationshipField from api.preprints.serializers import PreprintProviderRelationshipField +from osf.models import Node from website.util import api_v2_url -from api.base.serializers import JSONAPISerializer, LinksField - -NOTIFICATION_TYPES = { - 'none': 'none', - 'instant': 'email_transactional', - 'daily': 'email_digest', -} - - -class FrequencyField(ser.Field): - def to_representation(self, obj): - user_id = self.context['request'].user.id - if obj.email_transactional.filter(id=user_id).exists(): - return 'instant' - if obj.email_digest.filter(id=user_id).exists(): - return 'daily' - return 'none' - - def to_internal_value(self, frequency): - notification_type = NOTIFICATION_TYPES.get(frequency) - if notification_type: - return {'notification_type': notification_type} - raise ValidationError(f'Invalid frequency "{frequency}"') +from api.base.serializers import JSONAPISerializer +from .fields import FrequencyField class SubscriptionSerializer(JSONAPISerializer): filterable_fields = frozenset([ 'id', 'event_name', + 'frequency', ]) - id = ser.CharField(source='_id', read_only=True) + id = ser.CharField(read_only=True) event_name = ser.CharField(read_only=True) frequency = FrequencyField(source='*', required=True) - links = LinksField({ - 'self': 'get_absolute_url', - }) class Meta: type_ = 'subscription' - def get_absolute_url(self, obj): - return obj.absolute_api_v2_url - def update(self, instance, validated_data): user = self.context['request'].user - notification_type = validated_data.get('notification_type') - instance.add_user_to_subscription(user, notification_type, save=True) + frequency = validated_data.get('frequency') + + if frequency != 'none' and instance.content_type == ContentType.objects.get_for_model(Node): + node = Node.objects.get( + id=instance.id, + content_type=instance.content_type, + ) + user_subs = node.parent_node.child_node_subscriptions + if node._id not in user_subs.setdefault(user._id, []): + user_subs[user._id].append(node._id) + node.parent_node.save() + return instance diff --git a/api/subscriptions/views.py b/api/subscriptions/views.py index a3c11a52aa8..e8c48b421b9 100644 --- a/api/subscriptions/views.py +++ b/api/subscriptions/views.py @@ -1,8 +1,8 @@ +from pyasn1_modules.rfc5126 import ContentType from rest_framework import generics from rest_framework import permissions as drf_permissions from rest_framework.exceptions import NotFound from django.core.exceptions import ObjectDoesNotExist -from django.db.models import Q from framework.auth.oauth_scopes import CoreScopes from api.base.views import JSONAPIBaseView @@ -16,13 +16,12 @@ ) from api.subscriptions.permissions import IsSubscriptionOwner from osf.models import ( - NotificationSubscription, CollectionProvider, PreprintProvider, RegistrationProvider, AbstractProvider, ) -from osf.models.notifications import NotificationSubscriptionLegacy +from osf.models.notification import NotificationSubscription class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): @@ -38,32 +37,20 @@ class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): required_read_scopes = [CoreScopes.SUBSCRIPTIONS_READ] required_write_scopes = [CoreScopes.NULL] - def get_default_queryset(self): - user = self.request.user - return NotificationSubscriptionLegacy.objects.filter( - Q(none=user) | - Q(email_digest=user) | - Q( - email_transactional=user, - ), - ).distinct() - def get_queryset(self): - return self.get_queryset_from_request() + return NotificationSubscription.objects.filter( + user=self.request.user, + ) class AbstractProviderSubscriptionList(SubscriptionList): - def get_default_queryset(self): - user = self.request.user - return NotificationSubscriptionLegacy.objects.filter( - provider___id=self.kwargs['provider_id'], - provider__type=self.provider_class._typedmodels_type, - ).filter( - Q(none=user) | - Q(email_digest=user) | - Q(email_transactional=user), - ).distinct() - + def get_queryset(self): + provider = AbstractProvider.objects.get(_id=self.kwargs['provider_id']) + return NotificationSubscription.objects.filter( + object_id=provider, + provider__type=ContentType.objects.get_for_model(provider.__class__), + user=self.request.user, + ) class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView): view_name = 'notification-subscription-detail' @@ -81,7 +68,7 @@ class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView): def get_object(self): subscription_id = self.kwargs['subscription_id'] try: - obj = NotificationSubscriptionLegacy.objects.get(_id=subscription_id) + obj = NotificationSubscription.objects.get(id=subscription_id) except ObjectDoesNotExist: raise NotFound self.check_object_permissions(self.request, obj) @@ -101,33 +88,6 @@ class AbstractProviderSubscriptionDetail(SubscriptionDetail): required_write_scopes = [CoreScopes.SUBSCRIPTIONS_WRITE] provider_class = None - def __init__(self, *args, **kwargs): - assert issubclass(self.provider_class, AbstractProvider), 'Class must be subclass of AbstractProvider' - super().__init__(*args, **kwargs) - - def get_object(self): - subscription_id = self.kwargs['subscription_id'] - if self.kwargs.get('provider_id'): - provider = self.provider_class.objects.get(_id=self.kwargs.get('provider_id')) - try: - obj = NotificationSubscriptionLegacy.objects.get( - _id=subscription_id, - provider_id=provider.id, - ) - except ObjectDoesNotExist: - raise NotFound - else: - try: - obj = NotificationSubscriptionLegacy.objects.get( - _id=subscription_id, - provider__type=self.provider_class._typedmodels_type, - ) - except ObjectDoesNotExist: - raise NotFound - self.check_object_permissions(self.request, obj) - return obj - - class CollectionProviderSubscriptionDetail(AbstractProviderSubscriptionDetail): provider_class = CollectionProvider serializer_class = CollectionSubscriptionSerializer diff --git a/api_tests/subscriptions/views/test_subscriptions_detail.py b/api_tests/subscriptions/views/test_subscriptions_detail.py index f64c835ad10..a9d880c687f 100644 --- a/api_tests/subscriptions/views/test_subscriptions_detail.py +++ b/api_tests/subscriptions/views/test_subscriptions_detail.py @@ -1,8 +1,10 @@ import pytest from api.base.settings.defaults import API_BASE -from osf_tests.factories import AuthUserFactory, NotificationSubscriptionLegacyFactory - +from osf_tests.factories import ( + AuthUserFactory, + NotificationSubscriptionFactory +) @pytest.mark.django_db class TestSubscriptionDetail: @@ -16,18 +18,18 @@ def user_no_auth(self): return AuthUserFactory() @pytest.fixture() - def global_user_notification(self, user): - notification = NotificationSubscriptionLegacyFactory(_id=f'{user._id}_global', user=user, event_name='global') - notification.add_user_to_subscription(user, 'email_transactional') - return notification + def notification(self, user): + return NotificationSubscriptionFactory( + user=user, + ) @pytest.fixture() - def url(self, global_user_notification): - return f'/{API_BASE}subscriptions/{global_user_notification._id}/' + def url(self, notification): + return f'/{API_BASE}subscriptions/{notification.id}/' @pytest.fixture() def url_invalid(self): - return '/{}subscriptions/{}/'.format(API_BASE, 'invalid-notification-id') + return f'/{API_BASE}subscriptions/invalid-notification-id/' @pytest.fixture() def payload(self): @@ -51,56 +53,101 @@ def payload_invalid(self): } } - def test_subscription_detail(self, app, user, user_no_auth, global_user_notification, url, url_invalid, payload, payload_invalid): - # GET with valid notification_id - # Invalid user - res = app.get(url, auth=user_no_auth.auth, expect_errors=True) + def test_subscription_detail_invalid_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): + res = app.get( + url, + auth=user_no_auth.auth, + expect_errors=True + ) assert res.status_code == 403 - # No user - res = app.get(url, expect_errors=True) + + def test_subscription_detail_no_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): + res = app.get( + url, + expect_errors=True + ) assert res.status_code == 401 - # Valid user + + def test_subscription_detail_valid_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): + res = app.get(url, auth=user.auth) notification_id = res.json['data']['id'] assert res.status_code == 200 - assert notification_id == f'{user._id}_global' + assert notification_id == str(notification.id) - # GET with invalid notification_id - # No user + def test_subscription_detail_invalid_notification_id_no_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.get(url_invalid, expect_errors=True) assert res.status_code == 404 - # Existing user - res = app.get(url_invalid, auth=user.auth, expect_errors=True) + + def test_subscription_detail_invalid_notification_id_existing_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): + res = app.get( + url_invalid, + auth=user.auth, + expect_errors=True + ) assert res.status_code == 404 - # PATCH with valid notification_id and invalid data - # Invalid user + def test_subscription_detail_invalid_payload_403( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.patch_json_api(url, payload_invalid, auth=user_no_auth.auth, expect_errors=True) assert res.status_code == 403 - # No user + + def test_subscription_detail_invalid_payload_401( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.patch_json_api(url, payload_invalid, expect_errors=True) assert res.status_code == 401 - # Valid user - res = app.patch_json_api(url, payload_invalid, auth=user.auth, expect_errors=True) + + def test_subscription_detail_invalid_payload_400( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): + res = app.patch_json_api( + url, + payload_invalid, + auth=user.auth, + expect_errors=True + ) assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == 'Invalid frequency "invalid-frequency"' + assert res.json['errors'][0]['detail'] == '"invalid-frequency" is not a valid choice.' - # PATCH with invalid notification_id - # No user + def test_subscription_detail_patch_invalid_notification_id_no_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.patch_json_api(url_invalid, payload, expect_errors=True) assert res.status_code == 404 - # Existing user + + def test_subscription_detail_patch_invalid_notification_id_existing_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.patch_json_api(url_invalid, payload, auth=user.auth, expect_errors=True) assert res.status_code == 404 - # PATCH with valid notification_id and valid data - # Invalid user + def test_subscription_detail_patch_invalid_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.patch_json_api(url, payload, auth=user_no_auth.auth, expect_errors=True) assert res.status_code == 403 - # No user + + def test_subscription_detail_patch_no_user( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.patch_json_api(url, payload, expect_errors=True) assert res.status_code == 401 - # Valid user + + def test_subscription_detail_patch( + self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid + ): res = app.patch_json_api(url, payload, auth=user.auth) assert res.status_code == 200 assert res.json['data']['attributes']['frequency'] == 'none' diff --git a/osf/models/notification.py b/osf/models/notification.py index b95d5140ebc..6f0fae57067 100644 --- a/osf/models/notification.py +++ b/osf/models/notification.py @@ -296,6 +296,12 @@ def emit(self, user, subscribed_object=None, event_context=None): event_context=event_context ) + @property + def absolute_api_v2_url(self): + from api.base.utils import absolute_reverse + return absolute_reverse('institutions:institution-detail', kwargs={'institution_id': self._id, 'version': 'v2'}) + + class Notification(models.Model): subscription = models.ForeignKey( NotificationSubscription, diff --git a/osf_tests/factories.py b/osf_tests/factories.py index bf636677284..d5ece941465 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -1054,6 +1054,17 @@ class Meta: model = models.NotificationSubscriptionLegacy +class NotificationSubscriptionFactory(DjangoModelFactory): + class Meta: + model = models.NotificationSubscription + notification_type = factory.LazyAttribute(lambda o: NotificationTypeFactory()) + + +class NotificationTypeFactory(DjangoModelFactory): + class Meta: + model = models.NotificationType + + def make_node_lineage(): node1 = NodeFactory() node2 = NodeFactory(parent=node1) From f550c61146c82a7ba835c1746d2cb44ca6ccc08a Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Mon, 16 Jun 2025 10:46:40 -0400 Subject: [PATCH 3/9] fix absolute url issue --- api/subscriptions/serializers.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/subscriptions/serializers.py b/api/subscriptions/serializers.py index 2bb1041d227..17065db6029 100644 --- a/api/subscriptions/serializers.py +++ b/api/subscriptions/serializers.py @@ -7,7 +7,7 @@ from website.util import api_v2_url -from api.base.serializers import JSONAPISerializer +from api.base.serializers import JSONAPISerializer, LinksField from .fields import FrequencyField class SubscriptionSerializer(JSONAPISerializer): @@ -24,6 +24,13 @@ class SubscriptionSerializer(JSONAPISerializer): class Meta: type_ = 'subscription' + links = LinksField({ + 'self': 'get_absolute_url', + }) + + def get_absolute_url(self, obj): + return obj.absolute_api_v2_url + def update(self, instance, validated_data): user = self.context['request'].user frequency = validated_data.get('frequency') From 458fbfdf3e79f1e0de6c8d8b44fe03ee3c8fdb50 Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Mon, 16 Jun 2025 13:14:46 -0400 Subject: [PATCH 4/9] fix up unit test issues --- api/subscriptions/fields.py | 5 ++--- api/subscriptions/serializers.py | 2 +- osf/models/notification.py | 24 ++++++++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/api/subscriptions/fields.py b/api/subscriptions/fields.py index c26ffaf5d4e..ddbcd4f4aa5 100644 --- a/api/subscriptions/fields.py +++ b/api/subscriptions/fields.py @@ -1,12 +1,11 @@ from rest_framework import serializers as ser -from osf.models import NotificationSubscription class FrequencyField(ser.ChoiceField): def __init__(self, **kwargs): super().__init__(choices=['none', 'instantly', 'daily', 'weekly', 'monthly'], **kwargs) - def to_representation(self, obj: NotificationSubscription): - return obj.message_frequency + def to_representation(self, frequency: str): + return frequency or 'none' def to_internal_value(self, freq): return super().to_internal_value(freq) diff --git a/api/subscriptions/serializers.py b/api/subscriptions/serializers.py index 17065db6029..ceb6d602db7 100644 --- a/api/subscriptions/serializers.py +++ b/api/subscriptions/serializers.py @@ -19,7 +19,7 @@ class SubscriptionSerializer(JSONAPISerializer): id = ser.CharField(read_only=True) event_name = ser.CharField(read_only=True) - frequency = FrequencyField(source='*', required=True) + frequency = FrequencyField(source='message_frequency', required=True) class Meta: type_ = 'subscription' diff --git a/osf/models/notification.py b/osf/models/notification.py index 6f0fae57067..d2e4244cb0a 100644 --- a/osf/models/notification.py +++ b/osf/models/notification.py @@ -301,6 +301,30 @@ def absolute_api_v2_url(self): from api.base.utils import absolute_reverse return absolute_reverse('institutions:institution-detail', kwargs={'institution_id': self._id, 'version': 'v2'}) + from django.contrib.contenttypes.models import ContentType + + @property + def _id(self): + """ + Legacy subscription id for API compatibility. + Provider: _ + User/global: _global_ + Node/etc: _ + """ + # Safety checks + event = self.notification_type.name + ct = self.notification_type.object_content_type + match getattr(ct, 'model', None): + case 'preprintprovider' | 'collectionprovider' | 'registrationprovider': + # Providers: use subscribed_object._id (which is the provider short name, e.g. 'mindrxiv') + return f'{self.subscribed_object._id}_new_pending_submissions' + case 'node' | 'collection' | 'preprint': + # Node-like objects: use object_id (guid) + return f'{self.subscribed_object._id}_{event}' + case 'osfuser' | 'user', _: + # Global: _global + return f'{self.subscribed_object._id}_global_{event}' + class Notification(models.Model): subscription = models.ForeignKey( From 300524c5d537640018a3fdbe34516b72ccb50585 Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Wed, 2 Jul 2025 10:47:02 -0400 Subject: [PATCH 5/9] fix backward compat issues and remove old tests --- api/subscriptions/serializers.py | 6 +- api/subscriptions/views.py | 109 +- .../views/test_subscriptions_detail.py | 13 +- .../views/test_subscriptions_list.py | 46 +- osf/models/notification.py | 15 +- tests/test_notifications.py | 1587 ----------------- tests/test_user_profile_view.py | 73 +- 7 files changed, 158 insertions(+), 1691 deletions(-) delete mode 100644 tests/test_notifications.py diff --git a/api/subscriptions/serializers.py b/api/subscriptions/serializers.py index ceb6d602db7..d37a8342564 100644 --- a/api/subscriptions/serializers.py +++ b/api/subscriptions/serializers.py @@ -17,7 +17,11 @@ class SubscriptionSerializer(JSONAPISerializer): 'frequency', ]) - id = ser.CharField(read_only=True) + id = ser.CharField( + read_only=True, + source='legacy_id', + help_text='The id of the subscription fixed for backward compatibility', + ) event_name = ser.CharField(read_only=True) frequency = FrequencyField(source='message_frequency', required=True) diff --git a/api/subscriptions/views.py b/api/subscriptions/views.py index e8c48b421b9..8932b03ea67 100644 --- a/api/subscriptions/views.py +++ b/api/subscriptions/views.py @@ -1,8 +1,11 @@ -from pyasn1_modules.rfc5126 import ContentType +from django.db.models import Value, When, Case, F, Q, OuterRef, Subquery +from django.db.models.fields import CharField, IntegerField +from django.db.models.functions import Concat, Cast +from django.contrib.contenttypes.models import ContentType from rest_framework import generics from rest_framework import permissions as drf_permissions from rest_framework.exceptions import NotFound -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from framework.auth.oauth_scopes import CoreScopes from api.base.views import JSONAPIBaseView @@ -19,9 +22,9 @@ CollectionProvider, PreprintProvider, RegistrationProvider, - AbstractProvider, + AbstractProvider, AbstractNode, Preprint, OSFUser, ) -from osf.models.notification import NotificationSubscription +from osf.models.notification import NotificationSubscription, NotificationType class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): @@ -38,8 +41,47 @@ class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): required_write_scopes = [CoreScopes.NULL] def get_queryset(self): - return NotificationSubscription.objects.filter( - user=self.request.user, + user_guid = self.request.user._id + provider_ct = ContentType.objects.get(app_label='osf', model='abstractprovider') + + provider_subquery = AbstractProvider.objects.filter( + id=Cast(OuterRef('object_id'), IntegerField()), + ).values('_id')[:1] + + node_subquery = AbstractNode.objects.filter( + id=Cast(OuterRef('object_id'), IntegerField()), + ).values('guids___id')[:1] + + return NotificationSubscription.objects.filter(user=self.request.user).annotate( + event_name=Case( + When( + notification_type__name=NotificationType.Type.NODE_FILES_UPDATED.value, + then=Value('files_updated'), + ), + When( + notification_type__name=NotificationType.Type.USER_FILE_UPDATED.value, + then=Value('global_file_updated'), + ), + default=F('notification_type__name'), + output_field=CharField(), + ), + legacy_id=Case( + When( + notification_type__name=NotificationType.Type.NODE_FILES_UPDATED.value, + then=Concat(Subquery(node_subquery), Value('_file_updated')), + ), + When( + notification_type__name=NotificationType.Type.USER_FILE_UPDATED.value, + then=Value(f'{user_guid}_global'), + ), + When( + Q(notification_type__name=NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.value) & + Q(content_type=provider_ct), + then=Concat(Subquery(provider_subquery), Value('_new_pending_submissions')), + ), + default=F('notification_type__name'), + output_field=CharField(), + ), ) @@ -67,10 +109,63 @@ class SubscriptionDetail(JSONAPIBaseView, generics.RetrieveUpdateAPIView): def get_object(self): subscription_id = self.kwargs['subscription_id'] + user_guid = self.request.user._id + + provider_ct = ContentType.objects.get(app_label='osf', model='abstractprovider') + node_ct = ContentType.objects.get(app_label='osf', model='abstractnode') + + provider_subquery = AbstractProvider.objects.filter( + id=Cast(OuterRef('object_id'), IntegerField()), + ).values('_id')[:1] + + node_subquery = AbstractNode.objects.filter( + id=Cast(OuterRef('object_id'), IntegerField()), + ).values('guids___id')[:1] + + guid_id, *event_parts = subscription_id.split('_') + event = '_'.join(event_parts) if event_parts else '' + + subscription_obj = AbstractNode.load(guid_id) or Preprint.load(guid_id) or OSFUser.load(guid_id) + + if event != 'global': + obj_filter = Q( + object_id=getattr(subscription_obj, 'id', None), + content_type=ContentType.objects.get_for_model(subscription_obj.__class__), + notification_type__name__icontains=event, + ) + else: + obj_filter = Q() + try: - obj = NotificationSubscription.objects.get(id=subscription_id) + obj = NotificationSubscription.objects.annotate( + legacy_id=Case( + When( + notification_type__name=NotificationType.Type.NODE_FILES_UPDATED.value, + content_type=node_ct, + then=Concat(Subquery(node_subquery), Value('_file_updated')), + ), + When( + notification_type__name=NotificationType.Type.USER_FILE_UPDATED.value, + then=Value(f'{user_guid}_global'), + ), + When( + notification_type__name=NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.value, + content_type=provider_ct, + then=Concat(Subquery(provider_subquery), Value('_new_pending_submissions')), + ), + default=Value(f'{user_guid}_global'), + output_field=CharField(), + ), + ).filter(obj_filter) + except ObjectDoesNotExist: raise NotFound + + try: + obj = obj.filter(user=self.request.user).get() + except ObjectDoesNotExist: + raise PermissionDenied + self.check_object_permissions(self.request, obj) return obj diff --git a/api_tests/subscriptions/views/test_subscriptions_detail.py b/api_tests/subscriptions/views/test_subscriptions_detail.py index a9d880c687f..2d91e6b1083 100644 --- a/api_tests/subscriptions/views/test_subscriptions_detail.py +++ b/api_tests/subscriptions/views/test_subscriptions_detail.py @@ -19,13 +19,12 @@ def user_no_auth(self): @pytest.fixture() def notification(self, user): - return NotificationSubscriptionFactory( - user=user, - ) + return NotificationSubscriptionFactory(user=user) @pytest.fixture() def url(self, notification): - return f'/{API_BASE}subscriptions/{notification.id}/' + print('_id', notification._id) + return f'/{API_BASE}subscriptions/{notification._id}/' @pytest.fixture() def url_invalid(self): @@ -53,9 +52,7 @@ def payload_invalid(self): } } - def test_subscription_detail_invalid_user( - self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid - ): + def test_subscription_detail_invalid_user(self, app, user, user_no_auth, notification, url, payload): res = app.get( url, auth=user_no_auth.auth, @@ -79,7 +76,7 @@ def test_subscription_detail_valid_user( res = app.get(url, auth=user.auth) notification_id = res.json['data']['id'] assert res.status_code == 200 - assert notification_id == str(notification.id) + assert notification_id == f'{user._id}_global' def test_subscription_detail_invalid_notification_id_no_user( self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid diff --git a/api_tests/subscriptions/views/test_subscriptions_list.py b/api_tests/subscriptions/views/test_subscriptions_list.py index ad159e05a96..a0a01bf513c 100644 --- a/api_tests/subscriptions/views/test_subscriptions_list.py +++ b/api_tests/subscriptions/views/test_subscriptions_list.py @@ -1,8 +1,13 @@ import pytest from api.base.settings.defaults import API_BASE -from osf_tests.factories import AuthUserFactory, PreprintProviderFactory, ProjectFactory, \ - NotificationSubscriptionLegacyFactory, NotificationSubscriptionFactory +from osf.models import NotificationType +from osf_tests.factories import ( + AuthUserFactory, + PreprintProviderFactory, + ProjectFactory, + NotificationSubscriptionFactory +) @pytest.mark.django_db @@ -24,25 +29,42 @@ def node(self, user): @pytest.fixture() def global_user_notification(self, user): - notification = NotificationSubscriptionLegacyFactory(_id=f'{user._id}_global', user=user, event_name='global') - notification.add_user_to_subscription(user, 'email_transactional') - return notification + return NotificationSubscriptionFactory( + notification_type=NotificationType.Type.USER_FILE_UPDATED.instance, + user=user, + ) @pytest.fixture() def file_updated_notification(self, node, user): - notification = NotificationSubscriptionFactory( - _id=node._id + 'file_updated', - owner=node, - event_name='file_updated', + return NotificationSubscriptionFactory( + notification_type=NotificationType.Type.NODE_FILES_UPDATED.instance, + subscribed_object=node, + user=user, + ) + + @pytest.fixture() + def provider_notification(self, provider, user): + return NotificationSubscriptionFactory( + notification_type=NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.instance, + subscribed_object=provider, + user=user, ) - notification.add_user_to_subscription(user, 'email_transactional') - return notification @pytest.fixture() def url(self, user, node): return f'/{API_BASE}subscriptions/' - def test_list_complete(self, app, user, provider, node, global_user_notification, url): + def test_list_complete( + self, + app, + user, + provider, + node, + global_user_notification, + provider_notification, + file_updated_notification, + url + ): res = app.get(url, auth=user.auth) notification_ids = [item['id'] for item in res.json['data']] # There should only be 3 notifications: users' global, node's file updates and provider's preprint added. diff --git a/osf/models/notification.py b/osf/models/notification.py index d2e4244cb0a..7f05742cb88 100644 --- a/osf/models/notification.py +++ b/osf/models/notification.py @@ -100,6 +100,7 @@ class Type(str, Enum): NODE_PENDING_EMBARGO_TERMINATION_ADMIN = 'node_pending_embargo_termination_admin' # Provider notifications + PROVIDER_NEW_PENDING_SUBMISSIONS = 'provider_new_pending_submissions' PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION = 'provider_reviews_submission_confirmation' PROVIDER_REVIEWS_MODERATOR_SUBMISSION_CONFIRMATION = 'provider_reviews_moderator_submission_confirmation' PROVIDER_REVIEWS_WITHDRAWAL_REQUESTED = 'preprint_request_withdrawal_requested' @@ -119,7 +120,6 @@ class Type(str, Enum): PREPRINT_CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF = 'preprint_contributor_added_preprint_node_from_osf' # Collections Submission notifications - NEW_PENDING_SUBMISSIONS = 'new_pending_submissions' COLLECTION_SUBMISSION_REMOVED_ADMIN = 'collection_submission_removed_admin' COLLECTION_SUBMISSION_REMOVED_MODERATOR = 'collection_submission_removed_moderator' COLLECTION_SUBMISSION_REMOVED_PRIVATE = 'collection_submission_removed_private' @@ -136,6 +136,11 @@ class Type(str, Enum): REGISTRATION_BULK_UPLOAD_FAILURE_DUPLICATES = 'registration_bulk_upload_failure_duplicates' + @property + def instance(self): + obj, created = NotificationType.objects.get_or_create(name=self.value) + return obj + @classmethod def user_types(cls): return [member for member in cls if member.name.startswith('USER_')] @@ -271,7 +276,7 @@ def clean(self): raise ValidationError(f'{self.message_frequency!r} is not allowed for {self.notification_type.name!r}.') def __str__(self) -> str: - return f'{self.user} subscribes to {self.notification_type.name} ({self.message_frequency})' + return f'<{self.user} via {self.subscribed_object} subscribes to {self.notification_type.name} ({self.message_frequency})>' class Meta: verbose_name = 'Notification Subscription' @@ -321,9 +326,11 @@ def _id(self): case 'node' | 'collection' | 'preprint': # Node-like objects: use object_id (guid) return f'{self.subscribed_object._id}_{event}' - case 'osfuser' | 'user', _: + case 'osfuser' | 'user' | None: # Global: _global - return f'{self.subscribed_object._id}_global_{event}' + return f'{self.user._id}_global' + case _: + raise NotImplementedError() class Notification(models.Model): diff --git a/tests/test_notifications.py b/tests/test_notifications.py deleted file mode 100644 index db542f4640d..00000000000 --- a/tests/test_notifications.py +++ /dev/null @@ -1,1587 +0,0 @@ -import collections -from unittest import mock - -import pytest -from babel import dates, Locale -from schema import Schema, And, Use, Or -from django.utils import timezone - -from framework.auth import Auth -from osf.models import ( - Comment, - NotificationDigest, - NotificationSubscription, - Guid, - OSFUser, - NotificationSubscriptionLegacy -) - -from website.notifications.tasks import get_users_emails, send_users_email, group_by_node, remove_notifications -from website.notifications.exceptions import InvalidSubscriptionError -from website.notifications import constants -from website.notifications import emails -from website.notifications import utils -from website import mails -from website.profile.utils import get_profile_image_url -from website.project.signals import contributor_removed, node_deleted -from website.reviews import listeners -from website.util import api_url_for -from website.util import web_url_for -from website import settings - -from osf_tests import factories -from osf.utils import permissions -from tests.base import capture_signals -from tests.base import OsfTestCase, NotificationTestCase - - - -class TestNotificationsModels(OsfTestCase): - - def setUp(self): - super().setUp() - # Create project with component - self.user = factories.UserFactory() - self.consolidate_auth = Auth(user=self.user) - self.parent = factories.ProjectFactory(creator=self.user) - self.node = factories.NodeFactory(creator=self.user, parent=self.parent) - - def test_has_permission_on_children(self): - non_admin_user = factories.UserFactory() - parent = factories.ProjectFactory() - parent.add_contributor(contributor=non_admin_user, permissions=permissions.READ) - parent.save() - - node = factories.NodeFactory(parent=parent, category='project') - sub_component = factories.NodeFactory(parent=node) - sub_component.add_contributor(contributor=non_admin_user) - sub_component.save() - sub_component2 = factories.NodeFactory(parent=node) - - assert node.has_permission_on_children(non_admin_user, permissions.READ) - - def test_check_user_has_permission_excludes_deleted_components(self): - non_admin_user = factories.UserFactory() - parent = factories.ProjectFactory() - parent.add_contributor(contributor=non_admin_user, permissions=permissions.READ) - parent.save() - - node = factories.NodeFactory(parent=parent, category='project') - sub_component = factories.NodeFactory(parent=node) - sub_component.add_contributor(contributor=non_admin_user) - sub_component.is_deleted = True - sub_component.save() - sub_component2 = factories.NodeFactory(parent=node) - - assert not node.has_permission_on_children(non_admin_user, permissions.READ) - - def test_check_user_does_not_have_permission_on_private_node_child(self): - non_admin_user = factories.UserFactory() - parent = factories.ProjectFactory() - parent.add_contributor(contributor=non_admin_user, permissions=permissions.READ) - parent.save() - node = factories.NodeFactory(parent=parent, category='project') - sub_component = factories.NodeFactory(parent=node) - - assert not node.has_permission_on_children(non_admin_user,permissions.READ) - - def test_check_user_child_node_permissions_false_if_no_children(self): - non_admin_user = factories.UserFactory() - parent = factories.ProjectFactory() - parent.add_contributor(contributor=non_admin_user, permissions=permissions.READ) - parent.save() - node = factories.NodeFactory(parent=parent, category='project') - - assert not node.has_permission_on_children(non_admin_user,permissions.READ) - - def test_check_admin_has_permissions_on_private_component(self): - parent = factories.ProjectFactory() - node = factories.NodeFactory(parent=parent, category='project') - sub_component = factories.NodeFactory(parent=node) - - assert node.has_permission_on_children(parent.creator,permissions.READ) - - def test_check_user_private_node_child_permissions_excludes_pointers(self): - user = factories.UserFactory() - parent = factories.ProjectFactory() - pointed = factories.ProjectFactory(creator=user) - parent.add_pointer(pointed, Auth(parent.creator)) - parent.save() - - assert not parent.has_permission_on_children(user,permissions.READ) - - def test_new_project_creator_is_subscribed(self): - user = factories.UserFactory() - factories.ProjectFactory(creator=user) - user_subscriptions = list(utils.get_all_user_subscriptions(user)) - event_types = [sub.event_name for sub in user_subscriptions] - - assert len(user_subscriptions) == 1 # subscribed to file_updated - assert 'file_updated' in event_types - - def test_new_node_creator_is_not_subscribed(self): - user = factories.UserFactory() - factories.NodeFactory(creator=user) - user_subscriptions = list(utils.get_all_user_subscriptions(user)) - - assert len(user_subscriptions) == 0 - - def test_new_project_creator_is_subscribed_with_global_settings(self): - user = factories.UserFactory() - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comments', - user=user, - event_name='global_comments' - ).add_user_to_subscription(user, 'email_digest') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).add_user_to_subscription(user, 'none') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_mentions', - user=user, - event_name='global_mentions' - ).add_user_to_subscription(user, 'email_digest') - - node = factories.ProjectFactory(creator=user) - - user_subscriptions = list(utils.get_all_user_subscriptions(user)) - event_types = [sub.event_name for sub in user_subscriptions] - - file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') - comments_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_comments') - - assert len(user_subscriptions) == 2 # subscribed to both node and user settings - assert 'file_updated' in event_types - assert 'global_file_updated' in event_types - assert file_updated_subscription.none.count() == 1 - assert file_updated_subscription.email_transactional.count() == 0 - - def test_new_node_creator_is_not_subscribed_with_global_settings(self): - user = factories.UserFactory() - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comments', - user=user, - event_name='global_comments' - ).add_user_to_subscription(user, 'email_digest') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).add_user_to_subscription(user, 'none') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comment_replies', - user=user, - event_name='global_comment_replies' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_mentions', - user=user, - event_name='global_mentions' - ).add_user_to_subscription(user, 'email_transactional') - - node = factories.NodeFactory(creator=user) - - user_subscriptions = list(utils.get_all_user_subscriptions(user)) - event_types = [sub.event_name for sub in user_subscriptions] - - assert len(user_subscriptions) == 1 # subscribed to only user settings - assert 'global_file_updated' in event_types - - def test_subscribe_user_to_global_notfiications(self): - user = factories.UserFactory() - utils.subscribe_user_to_global_notifications(user) - subscription_event_names = list(user.notification_subscriptions.values_list('event_name', flat=True)) - for event_name in constants.USER_SUBSCRIPTIONS_AVAILABLE: - assert event_name in subscription_event_names - - def test_subscribe_user_to_registration_notifications(self): - registration = factories.RegistrationFactory() - with pytest.raises(InvalidSubscriptionError): - utils.subscribe_user_to_notifications(registration, self.user) - - def test_new_project_creator_is_subscribed_with_default_global_settings(self): - user = factories.UserFactory() - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comments', - user=user, - event_name='global_comments' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comment_replies', - user=user, - event_name='global_comment_replies' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_mentions', - user=user, - event_name='global_mentions' - ).add_user_to_subscription(user, 'email_transactional') - - node = factories.ProjectFactory(creator=user) - - user_subscriptions = list(utils.get_all_user_subscriptions(user)) - event_types = [sub.event_name for sub in user_subscriptions] - - file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') - comments_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_comments') - - assert len(user_subscriptions) == 2 # subscribed to both node and user settings - assert 'file_updated' in event_types - assert 'global_file_updated' in event_types - assert file_updated_subscription.email_transactional.count() == 1 - - def test_new_fork_creator_is_subscribed_with_default_global_settings(self): - user = factories.UserFactory() - project = factories.ProjectFactory(creator=user) - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comments', - user=user, - event_name='global_comments' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_mentions', - user=user, - event_name='global_mentions' - ).add_user_to_subscription(user, 'email_transactional') - - node = factories.ForkFactory(project=project) - - user_subscriptions = list(utils.get_all_user_subscriptions(user)) - event_types = [sub.event_name for sub in user_subscriptions] - - node_file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') - project_file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=project._id + '_file_updated') - - assert len(user_subscriptions) == 3 # subscribed to project, fork, and user settings - assert 'file_updated' in event_types - assert 'global_file_updated' in event_types - assert node_file_updated_subscription.email_transactional.count() == 1 - assert project_file_updated_subscription.email_transactional.count() == 1 - - def test_new_node_creator_is_not_subscribed_with_default_global_settings(self): - user = factories.UserFactory() - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comments', - user=user, - event_name='global_comments' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_comment_replies', - user=user, - event_name='global_comment_replies' - ).add_user_to_subscription(user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_' + 'global_mentions', - user=user, - event_name='global_mentions' - ).add_user_to_subscription(user, 'email_transactional') - - node = factories.NodeFactory(creator=user) - - user_subscriptions = list(utils.get_all_user_subscriptions(user)) - event_types = [sub.event_name for sub in user_subscriptions] - - assert len(user_subscriptions) == 1 # subscribed to only user settings - assert 'global_file_updated' in event_types - - - def test_contributor_subscribed_when_added_to_project(self): - user = factories.UserFactory() - contributor = factories.UserFactory() - project = factories.ProjectFactory(creator=user) - project.add_contributor(contributor=contributor) - contributor_subscriptions = list(utils.get_all_user_subscriptions(contributor)) - event_types = [sub.event_name for sub in contributor_subscriptions] - - assert len(contributor_subscriptions) == 1 - assert 'file_updated' in event_types - - def test_contributor_subscribed_when_added_to_component(self): - user = factories.UserFactory() - contributor = factories.UserFactory() - - factories.NotificationSubscriptionLegacyFactory( - _id=contributor._id + '_' + 'global_comments', - user=contributor, - event_name='global_comments' - ).add_user_to_subscription(contributor, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=contributor._id + '_' + 'global_file_updated', - user=contributor, - event_name='global_file_updated' - ).add_user_to_subscription(contributor, 'email_transactional') - - node = factories.NodeFactory(creator=user) - node.add_contributor(contributor=contributor) - - contributor_subscriptions = list(utils.get_all_user_subscriptions(contributor)) - event_types = [sub.event_name for sub in contributor_subscriptions] - - file_updated_subscription = NotificationSubscriptionLegacy.objects.get(_id=node._id + '_file_updated') - - assert len(contributor_subscriptions) == 2 # subscribed to both node and user settings - assert 'file_updated' in event_types - assert 'global_file_updated' in event_types - assert file_updated_subscription.email_transactional.count() == 1 - - def test_unregistered_contributor_not_subscribed_when_added_to_project(self): - user = factories.AuthUserFactory() - unregistered_contributor = factories.UnregUserFactory() - project = factories.ProjectFactory(creator=user) - project.add_unregistered_contributor( - unregistered_contributor.fullname, - unregistered_contributor.email, - Auth(user), - existing_user=unregistered_contributor - ) - - contributor_subscriptions = list(utils.get_all_user_subscriptions(unregistered_contributor)) - assert len(contributor_subscriptions) == 0 - - -class TestSubscriptionView(OsfTestCase): - - def setUp(self): - super().setUp() - self.node = factories.NodeFactory() - self.user = self.node.creator - self.registration = factories.RegistrationFactory(creator=self.user) - - def test_create_new_subscription(self): - payload = { - 'id': self.node._id, - 'event': 'comments', - 'notification_type': 'email_transactional' - } - url = api_url_for('configure_subscription') - self.app.post(url, json=payload, auth=self.node.creator.auth) - - # check that subscription was created - event_id = self.node._id + '_' + 'comments' - s = NotificationSubscriptionLegacy.objects.get(_id=event_id) - - # check that user was added to notification_type field - assert payload['id'] == s.owner._id - assert payload['event'] == s.event_name - assert self.node.creator in getattr(s, payload['notification_type']).all() - - # change subscription - new_payload = { - 'id': self.node._id, - 'event': 'comments', - 'notification_type': 'email_digest' - } - url = api_url_for('configure_subscription') - self.app.post(url, json=new_payload, auth=self.node.creator.auth) - s.reload() - assert not self.node.creator in getattr(s, payload['notification_type']).all() - assert self.node.creator in getattr(s, new_payload['notification_type']).all() - - def test_cannot_create_registration_subscription(self): - payload = { - 'id': self.registration._id, - 'event': 'comments', - 'notification_type': 'email_transactional' - } - url = api_url_for('configure_subscription') - res = self.app.post(url, json=payload, auth=self.registration.creator.auth) - assert res.status_code == 400 - - def test_adopt_parent_subscription_default(self): - payload = { - 'id': self.node._id, - 'event': 'comments', - 'notification_type': 'adopt_parent' - } - url = api_url_for('configure_subscription') - self.app.post(url, json=payload, auth=self.node.creator.auth) - event_id = self.node._id + '_' + 'comments' - # confirm subscription was created because parent had default subscription - s = NotificationSubscriptionLegacy.objects.filter(_id=event_id).count() - assert 0 == s - - def test_change_subscription_to_adopt_parent_subscription_removes_user(self): - payload = { - 'id': self.node._id, - 'event': 'comments', - 'notification_type': 'email_transactional' - } - url = api_url_for('configure_subscription') - self.app.post(url, json=payload, auth=self.node.creator.auth) - - # check that subscription was created - event_id = self.node._id + '_' + 'comments' - s = NotificationSubscriptionLegacy.objects.get(_id=event_id) - - # change subscription to adopt_parent - new_payload = { - 'id': self.node._id, - 'event': 'comments', - 'notification_type': 'adopt_parent' - } - url = api_url_for('configure_subscription') - self.app.post(url, json=new_payload, auth=self.node.creator.auth) - s.reload() - - # assert that user is removed from the subscription entirely - for n in constants.NOTIFICATION_TYPES: - assert not self.node.creator in getattr(s, n).all() - - def test_configure_subscription_adds_node_id_to_notifications_configured(self): - project = factories.ProjectFactory(creator=self.user) - assert not project._id in self.user.notifications_configured - payload = { - 'id': project._id, - 'event': 'comments', - 'notification_type': 'email_digest' - } - url = api_url_for('configure_subscription') - self.app.post(url, json=payload, auth=project.creator.auth) - - self.user.reload() - - assert project._id in self.user.notifications_configured - - -class TestRemoveContributor(OsfTestCase): - - def setUp(self): - super(OsfTestCase, self).setUp() - self.project = factories.ProjectFactory() - self.contributor = factories.UserFactory() - self.project.add_contributor(contributor=self.contributor, permissions=permissions.READ) - self.project.save() - - self.subscription = NotificationSubscriptionLegacy.objects.get( - node=self.project, - _id=self.project._id + '_comments' - ) - - self.node = factories.NodeFactory(parent=self.project) - self.node.add_contributor(contributor=self.project.creator, permissions=permissions.ADMIN) - self.node.save() - - self.node_subscription = NotificationSubscriptionLegacy.objects.get( - _id=self.node._id + '_comments', - node=self.node - ) - self.node_subscription.add_user_to_subscription(self.node.creator, 'email_transactional') - - def test_removed_non_admin_contributor_is_removed_from_subscriptions(self): - assert self.contributor in self.subscription.email_transactional.all() - self.project.remove_contributor(self.contributor, auth=Auth(self.project.creator)) - assert self.contributor not in self.project.contributors.all() - self.subscription.reload() - assert self.contributor not in self.subscription.email_transactional.all() - - def test_removed_non_parent_admin_contributor_is_removed_from_subscriptions(self): - assert self.node.creator in self.node_subscription.email_transactional.all() - self.node.remove_contributor(self.node.creator, auth=Auth(self.node.creator)) - assert self.node.creator not in self.node.contributors.all() - self.node_subscription.reload() - assert self.node.creator not in self.node_subscription.email_transactional.all() - - def test_removed_contributor_admin_on_parent_not_removed_from_node_subscription(self): - # Admin on parent project is removed as a contributor on a component. Check - # that admin is not removed from component subscriptions, as the admin - # now has read-only access. - assert self.project.creator in self.node_subscription.email_transactional.all() - self.node.remove_contributor(self.project.creator, auth=Auth(self.project.creator)) - assert self.project.creator not in self.node.contributors.all() - assert self.project.creator in self.node_subscription.email_transactional.all() - - def test_remove_contributor_signal_called_when_contributor_is_removed(self): - with capture_signals() as mock_signals: - self.project.remove_contributor(self.contributor, auth=Auth(self.project.creator)) - assert mock_signals.signals_sent() == {contributor_removed} - - -class TestRemoveNodeSignal(OsfTestCase): - - def test_node_subscriptions_and_backrefs_removed_when_node_is_deleted(self): - project = factories.ProjectFactory() - component = factories.NodeFactory(parent=project, creator=project.creator) - - s = NotificationSubscriptionLegacy.objects.filter(email_transactional=project.creator) - assert s.count() == 2 - - s = NotificationSubscriptionLegacy.objects.filter(email_transactional=component.creator) - assert s.count() == 2 - - with capture_signals() as mock_signals: - project.remove_node(auth=Auth(project.creator)) - project.reload() - component.reload() - - assert project.is_deleted - assert component.is_deleted - assert mock_signals.signals_sent() == {node_deleted} - - s = NotificationSubscriptionLegacy.objects.filter(email_transactional=project.creator) - assert s.count() == 0 - - s = NotificationSubscriptionLegacy.objects.filter(email_transactional=component.creator) - assert s.count() == 0 - - with pytest.raises(NotificationSubscriptionLegacy.DoesNotExist): - NotificationSubscriptionLegacy.objects.get(node=project) - - with pytest.raises(NotificationSubscriptionLegacy.DoesNotExist): - NotificationSubscriptionLegacy.objects.get(node=component) - - -def list_or_dict(data): - # Generator only returns lists or dicts from list or dict - if isinstance(data, dict): - for key in data: - if isinstance(data[key], dict) or isinstance(data[key], list): - yield data[key] - elif isinstance(data, list): - for item in data: - if isinstance(item, dict) or isinstance(item, list): - yield item - - -def has(data, sub_data): - # Recursive approach to look for a subset of data in data. - # WARNING: Don't use on huge structures - # :param data: Data structure - # :param sub_data: subset being checked for - # :return: True or False - try: - next(item for item in data if item == sub_data) - return True - except StopIteration: - lists_and_dicts = list_or_dict(data) - for item in lists_and_dicts: - if has(item, sub_data): - return True - return False - - -def subscription_schema(project, structure, level=0): - # builds a schema from a list of nodes and events - # :param project: validation type - # :param structure: list of nodes (another list) and events - # :return: schema - sub_list = [] - for item in list_or_dict(structure): - sub_list.append(subscription_schema(project, item, level=level+1)) - sub_list.append(event_schema(level)) - - node_schema = { - 'node': { - 'id': Use(type(project._id), error=f'node_id{level}'), - 'title': Use(type(project.title), error=f'node_title{level}'), - 'url': Use(type(project.url), error=f'node_{level}') - }, - 'kind': And(str, Use(lambda s: s in ('node', 'folder'), - error=f"kind didn't match node or folder {level}")), - 'nodeType': Use(lambda s: s in ('project', 'component'), error='nodeType not project or component'), - 'category': Use(lambda s: s in settings.NODE_CATEGORY_MAP, error='category not in settings.NODE_CATEGORY_MAP'), - 'permissions': { - 'view': Use(lambda s: s in (True, False), error='view permissions is not True/False') - }, - 'children': sub_list - } - if level == 0: - return Schema([node_schema]) - return node_schema - - -def event_schema(level=None): - return { - 'event': { - 'title': And(Use(str, error=f'event_title{level} not a string'), - Use(lambda s: s in constants.NOTIFICATION_TYPES, - error=f'event_title{level} not in list')), - 'description': And(Use(str, error=f'event_desc{level} not a string'), - Use(lambda s: s in constants.NODE_SUBSCRIPTIONS_AVAILABLE, - error=f'event_desc{level} not in list')), - 'notificationType': And(str, Or('adopt_parent', lambda s: s in constants.NOTIFICATION_TYPES)), - 'parent_notification_type': Or(None, 'adopt_parent', lambda s: s in constants.NOTIFICATION_TYPES) - }, - 'kind': 'event', - 'children': And(list, lambda l: len(l) == 0) - } - - -class TestNotificationUtils(OsfTestCase): - - def setUp(self): - super().setUp() - self.user = factories.UserFactory() - self.project = factories.ProjectFactory(creator=self.user) - - self.user.notifications_configured[self.project._id] = True - self.user.save() - - self.node = factories.NodeFactory(parent=self.project, creator=self.user) - - self.user_subscription = [ - factories.NotificationSubscriptionFactory( - _id=self.user._id + '_' + 'global_file_updated', - user=self.user, - event_name='global_file_updated' - )] - - for x in self.user_subscription: - x.save() - for x in self.user_subscription: - x.email_transactional.add(self.user) - for x in self.user_subscription: - x.save() - - def test_to_subscription_key(self): - key = utils.to_subscription_key('xyz', 'comments') - assert key == 'xyz_comments' - - def test_from_subscription_key(self): - parsed_key = utils.from_subscription_key('xyz_comment_replies') - assert parsed_key == { - 'uid': 'xyz', - 'event': 'comment_replies' - } - - def test_get_configured_project_ids_does_not_return_user_or_node_ids(self): - configured_nodes = utils.get_configured_projects(self.user) - configured_ids = [n._id for n in configured_nodes] - # No duplicates! - assert len(configured_nodes) == 1 - - assert self.project._id in configured_ids - assert self.node._id not in configured_ids - assert self.user._id not in configured_ids - - def test_get_configured_project_ids_excludes_deleted_projects(self): - project = factories.ProjectFactory() - project.is_deleted = True - project.save() - assert project not in utils.get_configured_projects(self.user) - - def test_get_configured_project_ids_excludes_node_with_project_category(self): - node = factories.NodeFactory(parent=self.project, category='project') - assert node not in utils.get_configured_projects(self.user) - - def test_get_configured_project_ids_includes_top_level_private_projects_if_subscriptions_on_node(self): - private_project = factories.ProjectFactory() - node = factories.NodeFactory(parent=private_project) - node_comments_subscription = factories.NotificationSubscriptionLegacyFactory( - _id=node._id + '_' + 'comments', - node=node, - event_name='comments' - ) - node_comments_subscription.save() - node_comments_subscription.email_transactional.add(node.creator) - node_comments_subscription.save() - - node.creator.notifications_configured[node._id] = True - node.creator.save() - configured_project_nodes = utils.get_configured_projects(node.creator) - assert private_project in configured_project_nodes - - def test_get_configured_project_ids_excludes_private_projects_if_no_subscriptions_on_node(self): - user = factories.UserFactory() - - private_project = factories.ProjectFactory() - node = factories.NodeFactory(parent=private_project) - node.add_contributor(user) - - utils.remove_contributor_from_subscriptions(node, user) - - configured_project_nodes = utils.get_configured_projects(user) - assert private_project not in configured_project_nodes - - - def test_format_data_node_settings(self): - data = utils.format_data(self.user, [self.node]) - event = { - 'event': { - 'title': 'comments', - 'description': constants.NODE_SUBSCRIPTIONS_AVAILABLE['comments'], - 'notificationType': 'email_transactional', - 'parent_notification_type': 'email_transactional' - }, - 'kind': 'event', - 'children': [] - } - schema = subscription_schema(self.project, ['event']) - assert schema.validate(data) - assert has(data, event) - - def test_format_includes_admin_view_only_component_subscriptions(self): - # Test private components in which parent project admins are not contributors still appear in their - # notifications settings. - node = factories.NodeFactory(parent=self.project) - data = utils.format_data(self.user, [self.project]) - event = { - 'event': { - 'title': 'comments', - 'description': constants.NODE_SUBSCRIPTIONS_AVAILABLE['comments'], - 'notificationType': 'adopt_parent', - 'parent_notification_type': 'email_transactional' - }, - 'kind': 'event', - 'children': [], - } - schema = subscription_schema(self.project, ['event', ['event'], ['event']]) - assert schema.validate(data) - assert has(data, event) - - def test_format_data_excludes_pointers(self): - project = factories.ProjectFactory() - pointed = factories.ProjectFactory() - project.add_pointer(pointed, Auth(project.creator)) - project.creator.notifications_configured[project._id] = True - project.creator.save() - configured_project_nodes = utils.get_configured_projects(project.creator) - data = utils.format_data(project.creator, configured_project_nodes) - event = { - 'event': { - 'title': 'comments', - 'description': constants.NODE_SUBSCRIPTIONS_AVAILABLE['comments'], - 'notificationType': 'email_transactional', - 'parent_notification_type': None - }, - 'kind': 'event', - 'children': [], - } - schema = subscription_schema(self.project, ['event']) - assert schema.validate(data) - assert has(data, event) - - def test_format_data_user_subscriptions_includes_private_parent_if_configured_children(self): - private_project = factories.ProjectFactory() - node = factories.NodeFactory(parent=private_project) - - node_comments_subscription = factories.NotificationSubscriptionLegacyFactory( - _id=node._id + '_' + 'comments', - node=node, - event_name='comments' - ) - node_comments_subscription.save() - node_comments_subscription.email_transactional.add(node.creator) - node_comments_subscription.save() - - node.creator.notifications_configured[node._id] = True - node.creator.save() - configured_project_nodes = utils.get_configured_projects(node.creator) - data = utils.format_data(node.creator, configured_project_nodes) - event = { - 'event': { - 'title': 'comments', - 'description': constants.NODE_SUBSCRIPTIONS_AVAILABLE['comments'], - 'notificationType': 'email_transactional', - 'parent_notification_type': None - }, - 'kind': 'event', - 'children': [], - } - schema = subscription_schema(self.project, ['event', ['event']]) - assert schema.validate(data) - assert has(data, event) - - def test_format_data_user_subscriptions_if_children_points_to_parent(self): - private_project = factories.ProjectFactory(creator=self.user) - node = factories.NodeFactory(parent=private_project, creator=self.user) - node.save() - node_comments_subscription = factories.NotificationSubscriptionLegacyFactory( - _id=node._id + '_' + 'comments', - node=node, - event_name='comments' - ) - node_comments_subscription.save() - node_comments_subscription.email_transactional.add(node.creator) - node_comments_subscription.save() - - node.creator.notifications_configured[node._id] = True - node.creator.save() - configured_project_nodes = utils.get_configured_projects(node.creator) - data = utils.format_data(node.creator, configured_project_nodes) - event = { - 'event': { - 'title': 'comments', - 'description': constants.NODE_SUBSCRIPTIONS_AVAILABLE['comments'], - 'notificationType': 'email_transactional', - 'parent_notification_type': None - }, - 'kind': 'event', - 'children': [], - } - schema = subscription_schema(self.project, ['event', ['event']]) - assert schema.validate(data) - assert has(data, event) - - def test_format_user_subscriptions(self): - data = utils.format_user_subscriptions(self.user) - expected = [ - { - 'event': { - 'title': 'global_file_updated', - 'description': constants.USER_SUBSCRIPTIONS_AVAILABLE['global_file_updated'], - 'notificationType': 'email_transactional', - 'parent_notification_type': None, - }, - 'kind': 'event', - 'children': [] - }, { - 'event': { - 'title': 'global_reviews', - 'description': constants.USER_SUBSCRIPTIONS_AVAILABLE['global_reviews'], - 'notificationType': 'email_transactional', - 'parent_notification_type': None - }, - 'kind': 'event', - 'children': [] - } - ] - - assert data == expected - - def test_format_data_user_settings(self): - data = utils.format_user_and_project_subscriptions(self.user) - expected = [ - { - 'node': { - 'id': self.user._id, - 'title': 'Default Notification Settings', - 'help': 'These are default settings for new projects you create or are added to. Modifying these settings will not modify settings on existing projects.' - }, - 'kind': 'heading', - 'children': utils.format_user_subscriptions(self.user) - }, - { - 'node': { - 'help': 'These are settings for each of your projects. Modifying these settings will only modify the settings for the selected project.', - 'id': '', - 'title': 'Project Notifications' - }, - 'kind': 'heading', - 'children': utils.format_data(self.user, utils.get_configured_projects(self.user)) - }] - assert data == expected - - -class TestCompileSubscriptions(NotificationTestCase): - def setUp(self): - super().setUp() - self.user_1 = factories.UserFactory() - self.user_2 = factories.UserFactory() - self.user_3 = factories.UserFactory() - self.user_4 = factories.UserFactory() - # Base project + 1 project shared with 3 + 1 project shared with 2 - self.base_project = factories.ProjectFactory(is_public=False, creator=self.user_1) - self.shared_node = factories.NodeFactory(parent=self.base_project, is_public=False, creator=self.user_1) - self.private_node = factories.NodeFactory(parent=self.base_project, is_public=False, creator=self.user_1) - # Adding contributors - for node in [self.base_project, self.shared_node, self.private_node]: - node.add_contributor(self.user_2, permissions=permissions.ADMIN) - self.base_project.add_contributor(self.user_3, permissions=permissions.WRITE) - self.shared_node.add_contributor(self.user_3, permissions=permissions.WRITE) - # Setting basic subscriptions - self.base_sub = factories.NotificationSubscriptionLegacyFactory( - _id=self.base_project._id + '_file_updated', - node=self.base_project, - event_name='file_updated' - ) - self.base_sub.save() - self.shared_sub = factories.NotificationSubscriptionLegacyFactory( - _id=self.shared_node._id + '_file_updated', - node=self.shared_node, - event_name='file_updated' - ) - self.shared_sub.save() - self.private_sub = factories.NotificationSubscriptionLegacyFactory( - _id=self.private_node._id + '_file_updated', - node=self.private_node, - event_name='file_updated' - ) - self.private_sub.save() - - def test_no_subscription(self): - node = factories.NodeFactory() - result = emails.compile_subscriptions(node, 'file_updated') - assert {'email_transactional': [], 'none': [], 'email_digest': []} == result - - def test_no_subscribers(self): - node = factories.NodeFactory() - node_sub = factories.NotificationSubscriptionLegacyFactory( - _id=node._id + '_file_updated', - node=node, - event_name='file_updated' - ) - node_sub.save() - result = emails.compile_subscriptions(node, 'file_updated') - assert {'email_transactional': [], 'none': [], 'email_digest': []} == result - - def test_creator_subbed_parent(self): - # Basic sub check - self.base_sub.email_transactional.add(self.user_1) - self.base_sub.save() - result = emails.compile_subscriptions(self.base_project, 'file_updated') - assert {'email_transactional': [self.user_1._id], 'none': [], 'email_digest': []} == result - - def test_creator_subbed_to_parent_from_child(self): - # checks the parent sub is the one to appear without a child sub - self.base_sub.email_transactional.add(self.user_1) - self.base_sub.save() - result = emails.compile_subscriptions(self.shared_node, 'file_updated') - assert {'email_transactional': [self.user_1._id], 'none': [], 'email_digest': []} == result - - def test_creator_subbed_to_both_from_child(self): - # checks that only one sub is in the list. - self.base_sub.email_transactional.add(self.user_1) - self.base_sub.save() - self.shared_sub.email_transactional.add(self.user_1) - self.shared_sub.save() - result = emails.compile_subscriptions(self.shared_node, 'file_updated') - assert {'email_transactional': [self.user_1._id], 'none': [], 'email_digest': []} == result - - def test_creator_diff_subs_to_both_from_child(self): - # Check that the child node sub overrides the parent node sub - self.base_sub.email_transactional.add(self.user_1) - self.base_sub.save() - self.shared_sub.none.add(self.user_1) - self.shared_sub.save() - result = emails.compile_subscriptions(self.shared_node, 'file_updated') - assert {'email_transactional': [], 'none': [self.user_1._id], 'email_digest': []} == result - - def test_user_wo_permission_on_child_node_not_listed(self): - # Tests to see if a user without permission gets an Email about a node they cannot see. - self.base_sub.email_transactional.add(self.user_3) - self.base_sub.save() - result = emails.compile_subscriptions(self.private_node, 'file_updated') - assert {'email_transactional': [], 'none': [], 'email_digest': []} == result - - def test_several_nodes_deep(self): - self.base_sub.email_transactional.add(self.user_1) - self.base_sub.save() - node2 = factories.NodeFactory(parent=self.shared_node) - node3 = factories.NodeFactory(parent=node2) - node4 = factories.NodeFactory(parent=node3) - node5 = factories.NodeFactory(parent=node4) - subs = emails.compile_subscriptions(node5, 'file_updated') - assert subs == {'email_transactional': [self.user_1._id], 'email_digest': [], 'none': []} - - def test_several_nodes_deep_precedence(self): - self.base_sub.email_transactional.add(self.user_1) - self.base_sub.save() - node2 = factories.NodeFactory(parent=self.shared_node) - node3 = factories.NodeFactory(parent=node2) - node4 = factories.NodeFactory(parent=node3) - node4_subscription = factories.NotificationSubscriptionLegacyFactory( - _id=node4._id + '_file_updated', - node=node4, - event_name='file_updated' - ) - node4_subscription.save() - node4_subscription.email_digest.add(self.user_1) - node4_subscription.save() - node5 = factories.NodeFactory(parent=node4) - subs = emails.compile_subscriptions(node5, 'file_updated') - assert subs == {'email_transactional': [], 'email_digest': [self.user_1._id], 'none': []} - - -class TestMoveSubscription(NotificationTestCase): - def setUp(self): - super().setUp() - self.blank = {key: [] for key in constants.NOTIFICATION_TYPES} # For use where it is blank. - self.user_1 = factories.AuthUserFactory() - self.auth = Auth(user=self.user_1) - self.user_2 = factories.AuthUserFactory() - self.user_3 = factories.AuthUserFactory() - self.user_4 = factories.AuthUserFactory() - self.project = factories.ProjectFactory(creator=self.user_1) - self.private_node = factories.NodeFactory(parent=self.project, is_public=False, creator=self.user_1) - self.sub = factories.NotificationSubscriptionLegacyFactory( - _id=self.project._id + '_file_updated', - node=self.project, - event_name='file_updated' - ) - self.sub.email_transactional.add(self.user_1) - self.sub.save() - self.file_sub = factories.NotificationSubscriptionLegacyFactory( - _id=self.project._id + '_xyz42_file_updated', - node=self.project, - event_name='xyz42_file_updated' - ) - self.file_sub.save() - - def test_separate_users(self): - self.private_node.add_contributor(self.user_2, permissions=permissions.ADMIN, auth=self.auth) - self.private_node.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.private_node.save() - subbed, removed = utils.separate_users( - self.private_node, [self.user_2._id, self.user_3._id, self.user_4._id] - ) - assert [self.user_2._id, self.user_3._id] == subbed - assert [self.user_4._id] == removed - - def test_event_subs_same(self): - self.file_sub.email_transactional.add(self.user_2, self.user_3, self.user_4) - self.file_sub.save() - self.private_node.add_contributor(self.user_2, permissions=permissions.ADMIN, auth=self.auth) - self.private_node.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.private_node.save() - results = utils.users_to_remove('xyz42_file_updated', self.project, self.private_node) - assert {'email_transactional': [self.user_4._id], 'email_digest': [], 'none': []} == results - - def test_event_nodes_same(self): - self.file_sub.email_transactional.add(self.user_2, self.user_3, self.user_4) - self.file_sub.save() - self.private_node.add_contributor(self.user_2, permissions=permissions.ADMIN, auth=self.auth) - self.private_node.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.private_node.save() - results = utils.users_to_remove('xyz42_file_updated', self.project, self.project) - assert {'email_transactional': [], 'email_digest': [], 'none': []} == results - - def test_move_sub(self): - # Tests old sub is replaced with new sub. - utils.move_subscription(self.blank, 'xyz42_file_updated', self.project, 'abc42_file_updated', self.private_node) - self.file_sub.reload() - assert 'abc42_file_updated' == self.file_sub.event_name - assert self.private_node == self.file_sub.owner - assert self.private_node._id + '_abc42_file_updated' == self.file_sub._id - - def test_move_sub_with_none(self): - # Attempt to reproduce an error that is seen when moving files - self.project.add_contributor(self.user_2, permissions=permissions.WRITE, auth=self.auth) - self.project.save() - self.file_sub.none.add(self.user_2) - self.file_sub.save() - results = utils.users_to_remove('xyz42_file_updated', self.project, self.private_node) - assert {'email_transactional': [], 'email_digest': [], 'none': [self.user_2._id]} == results - - def test_remove_one_user(self): - # One user doesn't have permissions on the node the sub is moved to. Should be listed. - self.file_sub.email_transactional.add(self.user_2, self.user_3, self.user_4) - self.file_sub.save() - self.private_node.add_contributor(self.user_2, permissions=permissions.ADMIN, auth=self.auth) - self.private_node.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.private_node.save() - results = utils.users_to_remove('xyz42_file_updated', self.project, self.private_node) - assert {'email_transactional': [self.user_4._id], 'email_digest': [], 'none': []} == results - - def test_remove_one_user_warn_another(self): - # Two users do not have permissions on new node, but one has a project sub. Both should be listed. - self.private_node.add_contributor(self.user_2, permissions=permissions.ADMIN, auth=self.auth) - self.private_node.save() - self.project.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.project.save() - self.sub.email_digest.add(self.user_3) - self.sub.save() - self.file_sub.email_transactional.add(self.user_2, self.user_4) - - results = utils.users_to_remove('xyz42_file_updated', self.project, self.private_node) - utils.move_subscription(results, 'xyz42_file_updated', self.project, 'abc42_file_updated', self.private_node) - assert {'email_transactional': [self.user_4._id], 'email_digest': [self.user_3._id], 'none': []} == results - assert self.sub.email_digest.filter(id=self.user_3.id).exists() # Is not removed from the project subscription. - - def test_warn_user(self): - # One user with a project sub does not have permission on new node. User should be listed. - self.private_node.add_contributor(self.user_2, permissions=permissions.ADMIN, auth=self.auth) - self.private_node.save() - self.project.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.project.save() - self.sub.email_digest.add(self.user_3) - self.sub.save() - self.file_sub.email_transactional.add(self.user_2) - results = utils.users_to_remove('xyz42_file_updated', self.project, self.private_node) - utils.move_subscription(results, 'xyz42_file_updated', self.project, 'abc42_file_updated', self.private_node) - assert {'email_transactional': [], 'email_digest': [self.user_3._id], 'none': []} == results - assert self.user_3 in self.sub.email_digest.all() # Is not removed from the project subscription. - - def test_user_node_subbed_and_not_removed(self): - self.project.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.project.save() - self.private_node.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.private_node.save() - self.sub.email_digest.add(self.user_3) - self.sub.save() - utils.move_subscription(self.blank, 'xyz42_file_updated', self.project, 'abc42_file_updated', self.private_node) - assert not self.file_sub.email_digest.filter().exists() - - # Regression test for commit ea15186 - def test_garrulous_event_name(self): - self.file_sub.email_transactional.add(self.user_2, self.user_3, self.user_4) - self.file_sub.save() - self.private_node.add_contributor(self.user_2, permissions=permissions.ADMIN, auth=self.auth) - self.private_node.add_contributor(self.user_3, permissions=permissions.WRITE, auth=self.auth) - self.private_node.save() - results = utils.users_to_remove('complicated/path_to/some/file/ASDFASDF.txt_file_updated', self.project, self.private_node) - assert {'email_transactional': [], 'email_digest': [], 'none': []} == results - -class TestSendEmails(NotificationTestCase): - def setUp(self): - super().setUp() - self.user = factories.AuthUserFactory() - self.project = factories.ProjectFactory() - self.user_subscription = factories.NotificationSubscriptionLegacyFactory( - _id=self.user._id + '_' + 'global_comment_replies', - node=self.node, - event_name='global_comment_replies' - ) - self.user_subscription.email_transactional.add(self.user) - self.user_subscription.save() - - @mock.patch('website.notifications.emails.store_emails') - def test_notify_mentions_does_not_send_to_mentioned_users_subscribed_to_none(self, mock_store): - node = factories.NodeFactory() - user = factories.UserFactory() - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_global_mentions', - node=self.node, - event_name='global_mentions' - ).add_user_to_subscription(user, 'none') - time_now = timezone.now() - sent = emails.notify_mentions('global_mentions', user=user, node=node, timestamp=time_now, new_mentions=[user._id]) - assert not mock_store.called - assert sent == [] - - @mock.patch('website.notifications.emails.store_emails') - def test_notify_mentions_does_send_to_mentioned_users(self, mock_store): - user = factories.UserFactory() - factories.NotificationSubscriptionLegacyFactory( - _id=user._id + '_global_mentions', - node=self.node, - event_name='global_mentions' - ).add_user_to_subscription(user, 'email_transactional') - node = factories.ProjectFactory(creator=user) - time_now = timezone.now() - emails.notify_mentions('global_mentions', user=user, node=node, timestamp=time_now, new_mentions=[user._id]) - assert mock_store.called - mock_store.assert_called_with( - [node.creator._id], - 'email_transactional', - 'global_mentions', - user, - node, - time_now, - template=None, - new_mentions=[node.creator._id], - is_creator=(user == node.creator), - ) - - def test_get_settings_url_for_node(self): - url = emails.get_settings_url(self.project._id, self.user) - assert url == self.project.absolute_url + 'settings/' - - def test_get_settings_url_for_user(self): - url = emails.get_settings_url(self.user._id, self.user) - assert url == web_url_for('user_notifications', _absolute=True) - - def test_get_node_lineage(self): - node_lineage = emails.get_node_lineage(self.node) - assert node_lineage == [self.project._id, self.node._id] - - def test_fix_locale(self): - assert emails.fix_locale('en') == 'en' - assert emails.fix_locale('de_DE') == 'de_DE' - assert emails.fix_locale('de_de') == 'de_DE' - - def test_localize_timestamp(self): - timestamp = timezone.now() - self.user.timezone = 'America/New_York' - self.user.locale = 'en_US' - self.user.save() - tz = dates.get_timezone(self.user.timezone) - locale = Locale(self.user.locale) - formatted_date = dates.format_date(timestamp, format='full', locale=locale) - formatted_time = dates.format_time(timestamp, format='short', tzinfo=tz, locale=locale) - formatted_datetime = f'{formatted_time} on {formatted_date}' - assert emails.localize_timestamp(timestamp, self.user) == formatted_datetime - - def test_localize_timestamp_empty_timezone(self): - timestamp = timezone.now() - self.user.timezone = '' - self.user.locale = 'en_US' - self.user.save() - tz = dates.get_timezone('Etc/UTC') - locale = Locale(self.user.locale) - formatted_date = dates.format_date(timestamp, format='full', locale=locale) - formatted_time = dates.format_time(timestamp, format='short', tzinfo=tz, locale=locale) - formatted_datetime = f'{formatted_time} on {formatted_date}' - assert emails.localize_timestamp(timestamp, self.user) == formatted_datetime - - def test_localize_timestamp_empty_locale(self): - timestamp = timezone.now() - self.user.timezone = 'America/New_York' - self.user.locale = '' - self.user.save() - tz = dates.get_timezone(self.user.timezone) - locale = Locale('en') - formatted_date = dates.format_date(timestamp, format='full', locale=locale) - formatted_time = dates.format_time(timestamp, format='short', tzinfo=tz, locale=locale) - formatted_datetime = f'{formatted_time} on {formatted_date}' - assert emails.localize_timestamp(timestamp, self.user) == formatted_datetime - - def test_localize_timestamp_handles_unicode(self): - timestamp = timezone.now() - self.user.timezone = 'Europe/Moscow' - self.user.locale = 'ru_RU' - self.user.save() - tz = dates.get_timezone(self.user.timezone) - locale = Locale(self.user.locale) - formatted_date = dates.format_date(timestamp, format='full', locale=locale) - formatted_time = dates.format_time(timestamp, format='short', tzinfo=tz, locale=locale) - formatted_datetime = f'{formatted_time} on {formatted_date}' - assert emails.localize_timestamp(timestamp, self.user) == formatted_datetime - - -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) -class TestSendDigest(OsfTestCase): - def setUp(self): - super().setUp() - self.user_1 = factories.UserFactory() - self.user_2 = factories.UserFactory() - self.project = factories.ProjectFactory() - self.timestamp = timezone.now() - - from conftest import start_mock_send_grid - self.mock_send_grid = start_mock_send_grid(self) - - def test_group_notifications_by_user_transactional(self): - send_type = 'email_transactional' - d = factories.NotificationDigestFactory( - user=self.user_1, - send_type=send_type, - timestamp=self.timestamp, - message='Hello', - node_lineage=[self.project._id] - ) - d.save() - d2 = factories.NotificationDigestFactory( - user=self.user_2, - send_type=send_type, - timestamp=self.timestamp, - message='Hello', - node_lineage=[self.project._id] - ) - d2.save() - d3 = factories.NotificationDigestFactory( - user=self.user_2, - send_type='email_digest', - timestamp=self.timestamp, - message='Hello, but this should not appear (this is a digest)', - node_lineage=[self.project._id] - ) - d3.save() - user_groups = list(get_users_emails(send_type)) - expected = [ - { - 'user_id': self.user_1._id, - 'info': [{ - 'message': 'Hello', - 'node_lineage': [str(self.project._id)], - '_id': d._id - }] - }, - { - 'user_id': self.user_2._id, - 'info': [{ - 'message': 'Hello', - 'node_lineage': [str(self.project._id)], - '_id': d2._id - }] - } - ] - - assert len(user_groups) == 2 - assert user_groups == expected - digest_ids = [d._id, d2._id, d3._id] - remove_notifications(email_notification_ids=digest_ids) - - def test_group_notifications_by_user_digest(self): - send_type = 'email_digest' - d2 = factories.NotificationDigestFactory( - user=self.user_2, - send_type=send_type, - timestamp=self.timestamp, - message='Hello', - node_lineage=[self.project._id] - ) - d2.save() - d3 = factories.NotificationDigestFactory( - user=self.user_2, - send_type='email_transactional', - timestamp=self.timestamp, - message='Hello, but this should not appear (this is transactional)', - node_lineage=[self.project._id] - ) - d3.save() - user_groups = list(get_users_emails(send_type)) - expected = [ - { - 'user_id': str(self.user_2._id), - 'info': [{ - 'message': 'Hello', - 'node_lineage': [str(self.project._id)], - '_id': str(d2._id) - }] - } - ] - - assert len(user_groups) == 1 - assert user_groups == expected - digest_ids = [d2._id, d3._id] - remove_notifications(email_notification_ids=digest_ids) - - def test_send_users_email_called_with_correct_args(self): - send_type = 'email_transactional' - d = factories.NotificationDigestFactory( - send_type=send_type, - event='comment_replies', - timestamp=timezone.now(), - message='Hello', - node_lineage=[factories.ProjectFactory()._id] - ) - d.save() - user_groups = list(get_users_emails(send_type)) - send_users_email(send_type) - mock_send_grid = self.mock_send_grid - assert mock_send_grid.called - assert mock_send_grid.call_count == len(user_groups) - - last_user_index = len(user_groups) - 1 - user = OSFUser.load(user_groups[last_user_index]['user_id']) - args, kwargs = mock_send_grid.call_args - - assert kwargs['to_addr'] == user.username - - def test_send_users_email_ignores_disabled_users(self): - send_type = 'email_transactional' - d = factories.NotificationDigestFactory( - send_type=send_type, - event='comment_replies', - timestamp=timezone.now(), - message='Hello', - node_lineage=[factories.ProjectFactory()._id] - ) - d.save() - - user_groups = list(get_users_emails(send_type)) - last_user_index = len(user_groups) - 1 - - user = OSFUser.load(user_groups[last_user_index]['user_id']) - user.is_disabled = True - user.save() - - send_users_email(send_type) - assert not self.mock_send_grid.called - - def test_remove_sent_digest_notifications(self): - d = factories.NotificationDigestFactory( - event='comment_replies', - timestamp=timezone.now(), - message='Hello', - node_lineage=[factories.ProjectFactory()._id] - ) - digest_id = d._id - remove_notifications(email_notification_ids=[digest_id]) - with pytest.raises(NotificationDigest.DoesNotExist): - NotificationDigest.objects.get(_id=digest_id) - - -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) -class TestNotificationsReviews(OsfTestCase): - def setUp(self): - super().setUp() - self.provider = factories.PreprintProviderFactory(_id='engrxiv') - self.preprint = factories.PreprintFactory(provider=self.provider) - self.user = factories.UserFactory() - self.sender = factories.UserFactory() - self.context_info = { - 'domain': 'osf.io', - 'reviewable': self.preprint, - 'workflow': 'pre-moderation', - 'provider_contact_email': settings.OSF_CONTACT_EMAIL, - 'provider_support_email': settings.OSF_SUPPORT_EMAIL, - 'document_type': 'preprint', - 'referrer': self.sender, - 'provider_url': self.provider.landing_url, - } - self.action = factories.ReviewActionFactory() - factories.NotificationSubscriptionLegacyFactory( - _id=self.user._id + '_' + 'global_comments', - user=self.user, - event_name='global_comments' - ).add_user_to_subscription(self.user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=self.user._id + '_' + 'global_file_updated', - user=self.user, - event_name='global_file_updated' - ).add_user_to_subscription(self.user, 'email_transactional') - - factories.NotificationSubscriptionLegacyFactory( - _id=self.user._id + '_' + 'global_reviews', - user=self.user, - event_name='global_reviews' - ).add_user_to_subscription(self.user, 'email_transactional') - - from conftest import start_mock_send_grid - self.mock_send_grid = start_mock_send_grid(self) - - def test_reviews_base_notification(self): - contributor_subscriptions = list(utils.get_all_user_subscriptions(self.user)) - event_types = [sub.event_name for sub in contributor_subscriptions] - assert 'global_reviews' in event_types - - def test_reviews_submit_notification(self): - listeners.reviews_submit_notification(self, context=self.context_info, recipients=[self.sender, self.user]) - assert self.mock_send_grid.called - - @mock.patch('website.notifications.emails.notify_global_event') - def test_reviews_notification(self, mock_notify): - listeners.reviews_notification(self, creator=self.sender, context=self.context_info, action=self.action, template='test.html.mako') - assert mock_notify.called - - -class QuerySetMatcher: - def __init__(self, some_obj): - self.some_obj = some_obj - - def __eq__(self, other): - return list(self.some_obj) == list(other) - - -class TestNotificationsReviewsModerator(OsfTestCase): - - def setUp(self): - super().setUp() - self.provider = factories.PreprintProviderFactory(_id='engrxiv') - self.preprint = factories.PreprintFactory(provider=self.provider) - self.submitter = factories.UserFactory() - self.moderator_transacitonal = factories.UserFactory() - self.moderator_digest= factories.UserFactory() - - self.context_info_submission = { - 'referrer': self.submitter, - 'domain': 'osf.io', - 'reviewable': self.preprint, - 'workflow': 'pre-moderation', - 'provider_contact_email': settings.OSF_CONTACT_EMAIL, - 'provider_support_email': settings.OSF_SUPPORT_EMAIL, - } - - self.context_info_request = { - 'requester': self.submitter, - 'domain': 'osf.io', - 'reviewable': self.preprint, - 'workflow': 'pre-moderation', - 'provider_contact_email': settings.OSF_CONTACT_EMAIL, - 'provider_support_email': settings.OSF_SUPPORT_EMAIL, - } - - self.action = factories.ReviewActionFactory() - self.subscription = NotificationSubscriptionLegacy.load(self.provider._id+'_new_pending_submissions') - self.subscription.add_user_to_subscription(self.moderator_transacitonal, 'email_transactional') - self.subscription.add_user_to_subscription(self.moderator_digest, 'email_digest') - - @mock.patch('website.notifications.emails.store_emails') - def test_reviews_submit_notification(self, mock_store): - time_now = timezone.now() - - preprint = self.context_info_submission['reviewable'] - provider = preprint.provider - - self.context_info_submission['message'] = f'submitted {preprint.title}.' - self.context_info_submission['profile_image_url'] = get_profile_image_url(self.context_info_submission['referrer']) - self.context_info_submission['reviews_submission_url'] = f'{settings.DOMAIN}reviews/preprints/{provider._id}/{preprint._id}' - listeners.reviews_submit_notification_moderators(self, time_now, self.context_info_submission) - subscription = NotificationSubscriptionLegacy.load(self.provider._id + '_new_pending_submissions') - digest_subscriber_ids = list(subscription.email_digest.all().values_list('guids___id', flat=True)) - instant_subscriber_ids = list(subscription.email_transactional.all().values_list('guids___id', flat=True)) - - mock_store.assert_any_call( - digest_subscriber_ids, - 'email_digest', - 'new_pending_submissions', - self.context_info_submission['referrer'], - self.context_info_submission['reviewable'], - time_now, - abstract_provider=self.context_info_submission['reviewable'].provider, - **self.context_info_submission - ) - - mock_store.assert_any_call( - instant_subscriber_ids, - 'email_transactional', - 'new_pending_submissions', - self.context_info_submission['referrer'], - self.context_info_submission['reviewable'], - time_now, - abstract_provider=self.context_info_request['reviewable'].provider, - **self.context_info_submission - ) - - @mock.patch('website.notifications.emails.store_emails') - def test_reviews_request_notification(self, mock_store): - time_now = timezone.now() - self.context_info_request['message'] = 'has requested withdrawal of {} "{}".'.format(self.context_info_request['reviewable'].provider.preprint_word, - self.context_info_request['reviewable'].title) - self.context_info_request['profile_image_url'] = get_profile_image_url(self.context_info_request['requester']) - self.context_info_request['reviews_submission_url'] = '{}reviews/preprints/{}/{}'.format(settings.DOMAIN, - self.context_info_request[ - 'reviewable'].provider._id, - self.context_info_request[ - 'reviewable']._id) - listeners.reviews_withdrawal_requests_notification(self, time_now, self.context_info_request) - subscription = NotificationSubscriptionLegacy.load(self.provider._id + '_new_pending_submissions') - digest_subscriber_ids = subscription.email_digest.all().values_list('guids___id', flat=True) - instant_subscriber_ids = subscription.email_transactional.all().values_list('guids___id', flat=True) - mock_store.assert_any_call(QuerySetMatcher(digest_subscriber_ids), - 'email_digest', - 'new_pending_submissions', - self.context_info_request['requester'], - self.context_info_request['reviewable'], - time_now, - abstract_provider=self.context_info_request['reviewable'].provider, - **self.context_info_request) - - mock_store.assert_any_call(QuerySetMatcher(instant_subscriber_ids), - 'email_transactional', - 'new_pending_submissions', - self.context_info_request['requester'], - self.context_info_request['reviewable'], - time_now, - abstract_provider=self.context_info_request['reviewable'].provider, - **self.context_info_request) diff --git a/tests/test_user_profile_view.py b/tests/test_user_profile_view.py index 8403a9d63c9..bb801340423 100644 --- a/tests/test_user_profile_view.py +++ b/tests/test_user_profile_view.py @@ -1,102 +1,31 @@ #!/usr/bin/env python3 """Views tests for the OSF.""" -from unittest.mock import MagicMock, ANY -from urllib import parse - -import datetime as dt -import time -import unittest from hashlib import md5 -from http.cookies import SimpleCookie from unittest import mock -from urllib.parse import quote_plus import pytest -from django.core.exceptions import ValidationError -from django.utils import timezone -from flask import request, g -from lxml import html -from pytest import approx from rest_framework import status as http_status from addons.github.tests.factories import GitHubAccountFactory -from addons.osfstorage import settings as osfstorage_settings -from addons.wiki.models import WikiPage -from framework import auth -from framework.auth import Auth, authenticate, cas, core -from framework.auth.campaigns import ( - get_campaigns, - is_institution_login, - is_native_login, - is_proxy_login, - campaign_url_for -) -from framework.auth.exceptions import InvalidTokenError -from framework.auth.utils import impute_names_model, ensure_external_identity_uniqueness -from framework.auth.views import login_and_register_handler from framework.celery_tasks import handlers -from framework.exceptions import HTTPError, TemplateHTTPError -from framework.flask import redirect -from framework.transactions.handlers import no_auto_transaction from osf.external.spam import tasks as spam_tasks from osf.models import ( - Comment, - AbstractNode, - OSFUser, - Tag, - SpamStatus, - NodeRelation, NotableDomain ) -from osf.utils import permissions from osf_tests.factories import ( fake_email, ApiOAuth2ApplicationFactory, ApiOAuth2PersonalTokenFactory, AuthUserFactory, - CollectionFactory, - CommentFactory, - NodeFactory, - PreprintFactory, - PreprintProviderFactory, - PrivateLinkFactory, - ProjectFactory, - ProjectWithAddonFactory, - RegistrationProviderFactory, - UserFactory, - UnconfirmedUserFactory, - UnregUserFactory, RegionFactory, - DraftRegistrationFactory, ) from tests.base import ( - assert_is_redirect, - capture_signals, fake, - get_default_metaschema, OsfTestCase, - assert_datetime_equal, - test_app -) -from tests.test_cas_authentication import generate_external_user_with_resp -from tests.utils import run_celery_tasks -from website import mailchimp_utils, mails, settings, language -from website.profile.utils import add_contributor_json, serialize_unregistered -from website.profile.views import update_osf_help_mails_subscription -from website.project.decorators import check_can_access -from website.project.model import has_anonymous_link -from website.project.signals import contributor_added -from website.project.views.contributor import ( - deserialize_contributors, - notify_added_contributor, - send_claim_email, - send_claim_registered_email, ) -from website.project.views.node import _should_show_wiki_widget, abbrev_authors +from website import mailchimp_utils from website.settings import MAILCHIMP_GENERAL_LIST from website.util import api_url_for, web_url_for -from website.util import rubeus -from website.util.metrics import OsfSourceTags, OsfClaimedTags, provider_source_tag, provider_claimed_tag from conftest import start_mock_send_grid From 85e134277de100240bbee375b7b5040523e94d84 Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Mon, 7 Jul 2025 10:31:59 -0400 Subject: [PATCH 6/9] split notification models into 3 files and improve interval choices --- api/subscriptions/permissions.py | 2 +- api/subscriptions/views.py | 3 +- ...tificationsubscription_options_and_more.py | 132 +++++++ .../0032_new_notifications_model.py | 104 ------ osf/models/__init__.py | 9 +- osf/models/notification.py | 330 +----------------- osf/models/notification_subscription.py | 102 ++++++ osf/models/notification_type.py | 247 +++++++++++++ 8 files changed, 489 insertions(+), 440 deletions(-) create mode 100644 osf/migrations/0032_alter_notificationsubscription_options_and_more.py delete mode 100644 osf/migrations/0032_new_notifications_model.py create mode 100644 osf/models/notification_subscription.py create mode 100644 osf/models/notification_type.py diff --git a/api/subscriptions/permissions.py b/api/subscriptions/permissions.py index a07eae6e81d..b22831f2766 100644 --- a/api/subscriptions/permissions.py +++ b/api/subscriptions/permissions.py @@ -1,6 +1,6 @@ from rest_framework import permissions -from osf.models.notification import NotificationSubscription +from osf.models.notification_subscription import NotificationSubscription class IsSubscriptionOwner(permissions.BasePermission): diff --git a/api/subscriptions/views.py b/api/subscriptions/views.py index 8932b03ea67..57a4dbf36c7 100644 --- a/api/subscriptions/views.py +++ b/api/subscriptions/views.py @@ -24,7 +24,8 @@ RegistrationProvider, AbstractProvider, AbstractNode, Preprint, OSFUser, ) -from osf.models.notification import NotificationSubscription, NotificationType +from osf.models.notification_type import NotificationType +from osf.models.notification_subscription import NotificationSubscription class SubscriptionList(JSONAPIBaseView, generics.ListAPIView, ListFilterMixin): diff --git a/osf/migrations/0032_alter_notificationsubscription_options_and_more.py b/osf/migrations/0032_alter_notificationsubscription_options_and_more.py new file mode 100644 index 00000000000..faa9ebdca19 --- /dev/null +++ b/osf/migrations/0032_alter_notificationsubscription_options_and_more.py @@ -0,0 +1,132 @@ +# Generated by Django 4.2.13 on 2025-07-07 14:24 + +from django.conf import settings +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import osf.models.base +import osf.models.notification_type + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('osf', '0031_alter_osfgroupgroupobjectpermission_unique_together_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='notificationsubscription', + options={'verbose_name': 'Notification Subscription', 'verbose_name_plural': 'Notification Subscriptions'}, + ), + migrations.AlterUniqueTogether( + name='notificationsubscription', + unique_together=set(), + ), + migrations.AddField( + model_name='notificationsubscription', + name='content_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + migrations.AddField( + model_name='notificationsubscription', + name='message_frequency', + field=models.CharField(max_length=500, null=True), + ), + migrations.AddField( + model_name='notificationsubscription', + name='object_id', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='notificationsubscription', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL), + ), + migrations.CreateModel( + name='NotificationType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_interval_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=32), blank=True, default=osf.models.notification_type.get_default_frequency_choices, size=None)), + ('name', models.CharField(max_length=255, unique=True)), + ('template', models.TextField(help_text='Template used to render the event_info. Supports Django template syntax.')), + ('subject', models.TextField(blank=True, help_text='Template used to render the subject line of email. Supports Django template syntax.', null=True)), + ('object_content_type', models.ForeignKey(blank=True, help_text='Content type for subscribed objects. Null means global event.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='contenttypes.contenttype')), + ], + options={ + 'verbose_name': 'Notification Type', + 'verbose_name_plural': 'Notification Types', + }, + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_context', models.JSONField()), + ('sent', models.DateTimeField(blank=True, null=True)), + ('seen', models.DateTimeField(blank=True, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='osf.notificationsubscription')), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + }, + ), + migrations.RemoveField( + model_name='notificationsubscription', + name='_id', + ), + migrations.RemoveField( + model_name='notificationsubscription', + name='email_digest', + ), + migrations.RemoveField( + model_name='notificationsubscription', + name='email_transactional', + ), + migrations.RemoveField( + model_name='notificationsubscription', + name='event_name', + ), + migrations.RemoveField( + model_name='notificationsubscription', + name='node', + ), + migrations.RemoveField( + model_name='notificationsubscription', + name='none', + ), + migrations.RemoveField( + model_name='notificationsubscription', + name='provider', + ), + migrations.AddField( + model_name='notificationsubscription', + name='notification_type', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='osf.notificationtype'), + ), + migrations.CreateModel( + name='NotificationSubscriptionLegacy', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('_id', models.CharField(db_index=True, max_length=100)), + ('event_name', models.CharField(max_length=100)), + ('email_digest', models.ManyToManyField(related_name='+', to=settings.AUTH_USER_MODEL)), + ('email_transactional', models.ManyToManyField(related_name='+', to=settings.AUTH_USER_MODEL)), + ('node', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_subscriptions', to='osf.node')), + ('none', models.ManyToManyField(related_name='+', to=settings.AUTH_USER_MODEL)), + ('provider', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_subscriptions', to='osf.abstractprovider')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'osf_notificationsubscription_legacy', + 'unique_together': {('_id', 'provider')}, + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + ] diff --git a/osf/migrations/0032_new_notifications_model.py b/osf/migrations/0032_new_notifications_model.py deleted file mode 100644 index 97b707dabb8..00000000000 --- a/osf/migrations/0032_new_notifications_model.py +++ /dev/null @@ -1,104 +0,0 @@ -import osf -from django.db import migrations, models -from django.conf import settings -import django_extensions.db.fields -import django.db.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('osf', '0031_alter_osfgroupgroupobjectpermission_unique_together_and_more'), - ] - - operations = [ - migrations.RunSQL( - """ - DO $$ - DECLARE - idx record; - BEGIN - FOR idx IN - SELECT indexname - FROM pg_indexes - WHERE tablename = 'osf_notificationsubscription' - LOOP - EXECUTE format( - 'ALTER INDEX %I RENAME TO %I', - idx.indexname, - replace(idx.indexname, 'osf_notificationsubscription', 'osf_notificationsubscription_legacy') - ); - END LOOP; - END$$; - """ - ), - migrations.AlterModelTable( - name='NotificationSubscription', - table='osf_notificationsubscription_legacy', - ), - - migrations.RenameModel( - old_name='NotificationSubscription', - new_name='NotificationSubscriptionLegacy', - ), - migrations.CreateModel( - name='NotificationType', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255, unique=True)), - ('notification_freq', models.CharField( - choices=[('none', 'None'), ('instantly', 'Instantly'), ('daily', 'Daily'), ('weekly', 'Weekly'), - ('monthly', 'Monthly')], default='instantly', max_length=32)), - ('template', models.TextField( - help_text='Template used to render the event_info. Supports Django template syntax.')), - ('object_content_type', models.ForeignKey(blank=True, - help_text='Content type for subscribed objects. Null means global event.', - null=True, on_delete=django.db.models.deletion.SET_NULL, - to='contenttypes.contenttype')), - ], - options={ - 'verbose_name': 'Notification Type', - 'verbose_name_plural': 'Notification Types', - }, - ), - migrations.CreateModel( - name='NotificationSubscription', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('created', - django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), - ('modified', - django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), - ('message_frequency', models.CharField(max_length=32)), - ('object_id', models.CharField(blank=True, max_length=255, null=True)), - ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, - to='contenttypes.contenttype')), - ('notification_type', - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='osf.notificationtype')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', - to=settings.AUTH_USER_MODEL)), - ], - options={ - 'verbose_name': 'Notification Subscription', - 'verbose_name_plural': 'Notification Subscriptions', - }, - bases=(models.Model, osf.models.base.QuerySetExplainMixin), - ), - migrations.CreateModel( - name='Notification', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('event_context', models.JSONField()), - ('sent', models.DateTimeField(blank=True, null=True)), - ('seen', models.DateTimeField(blank=True, null=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('subscription', - models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', - to='osf.notificationsubscription')), - ], - options={ - 'verbose_name': 'Notification', - 'verbose_name_plural': 'Notifications', - }, - ) - ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index 5f3c3696cdb..d09e350adfe 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -63,11 +63,10 @@ from .nodelog import NodeLog from .notable_domain import NotableDomain, DomainReference from .notifications import NotificationDigest, NotificationSubscriptionLegacy -from .notification import ( - NotificationSubscription, - Notification, - NotificationType -) +from .notification_subscription import NotificationSubscription +from .notification_type import NotificationType +from .notification import Notification + from .oauth import ( ApiOAuth2Application, ApiOAuth2PersonalToken, diff --git a/osf/models/notification.py b/osf/models/notification.py index 7f05742cb88..14fc4fd3155 100644 --- a/osf/models/notification.py +++ b/osf/models/notification.py @@ -1,341 +1,13 @@ import logging from django.db import models -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError -from django.template import Template, TemplateSyntaxError -from .base import BaseModel -from enum import Enum from website import settings from api.base import settings as api_settings from osf import email - -class FrequencyChoices(Enum): - NONE = 'none' - INSTANTLY = 'instantly' - DAILY = 'daily' - WEEKLY = 'weekly' - MONTHLY = 'monthly' - - @classmethod - def choices(cls): - return [(key.value, key.name.capitalize()) for key in cls] - - -class NotificationType(models.Model): - class Type(str, Enum): - # Desk notifications - DESK_REQUEST_EXPORT = 'desk_request_export' - DESK_REQUEST_DEACTIVATION = 'desk_request_deactivation' - DESK_OSF_SUPPORT_EMAIL = 'desk_osf_support_email' - DESK_REGISTRATION_BULK_UPLOAD_PRODUCT_OWNER = 'desk_registration_bulk_upload_product_owner' - DESK_USER_REGISTRATION_BULK_UPLOAD_UNEXPECTED_FAILURE = 'desk_user_registration_bulk_upload_unexpected_failure' - DESK_ARCHIVE_JOB_EXCEEDED = 'desk_archive_job_exceeded' - DESK_ARCHIVE_JOB_COPY_ERROR = 'desk_archive_job_copy_error' - DESK_ARCHIVE_JOB_FILE_NOT_FOUND = 'desk_archive_job_file_not_found' - DESK_ARCHIVE_JOB_UNCAUGHT_ERROR = 'desk_archive_job_uncaught_error' - - # User notifications - USER_PENDING_VERIFICATION = 'user_pending_verification' - USER_PENDING_VERIFICATION_REGISTERED = 'user_pending_verification_registered' - USER_STORAGE_CAP_EXCEEDED_ANNOUNCEMENT = 'user_storage_cap_exceeded_announcement' - USER_SPAM_BANNED = 'user_spam_banned' - USER_REQUEST_DEACTIVATION_COMPLETE = 'user_request_deactivation_complete' - USER_PRIMARY_EMAIL_CHANGED = 'user_primary_email_changed' - USER_INSTITUTION_DEACTIVATION = 'user_institution_deactivation' - USER_FORGOT_PASSWORD = 'user_forgot_password' - USER_FORGOT_PASSWORD_INSTITUTION = 'user_forgot_password_institution' - USER_REQUEST_EXPORT = 'user_request_export' - USER_CONTRIBUTOR_ADDED_OSF_PREPRINT = 'user_contributor_added_osf_preprint' - USER_CONTRIBUTOR_ADDED_DEFAULT = 'user_contributor_added_default' - USER_DUPLICATE_ACCOUNTS_OSF4I = 'user_duplicate_accounts_osf4i' - USER_EXTERNAL_LOGIN_LINK_SUCCESS = 'user_external_login_link_success' - USER_REGISTRATION_BULK_UPLOAD_FAILURE_ALL = 'user_registration_bulk_upload_failure_all' - USER_REGISTRATION_BULK_UPLOAD_SUCCESS_PARTIAL = 'user_registration_bulk_upload_success_partial' - USER_REGISTRATION_BULK_UPLOAD_SUCCESS_ALL = 'user_registration_bulk_upload_success_all' - USER_ADD_SSO_EMAIL_OSF4I = 'user_add_sso_email_osf4i' - USER_WELCOME_OSF4I = 'user_welcome_osf4i' - USER_ARCHIVE_JOB_EXCEEDED = 'user_archive_job_exceeded' - USER_ARCHIVE_JOB_COPY_ERROR = 'user_archive_job_copy_error' - USER_ARCHIVE_JOB_FILE_NOT_FOUND = 'user_archive_job_file_not_found' - USER_ARCHIVE_JOB_UNCAUGHT_ERROR = 'user_archive_job_uncaught_error' - USER_COMMENT_REPLIES = 'user_comment_replies' - USER_COMMENTS = 'user_comments' - USER_FILE_UPDATED = 'user_file_updated' - USER_COMMENT_MENTIONS = 'user_mentions' - USER_REVIEWS = 'user_reviews' - USER_PASSWORD_RESET = 'user_password_reset' - USER_CONTRIBUTOR_ADDED_DRAFT_REGISTRATION = 'user_contributor_added_draft_registration' - USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_CREATE = 'user_external_login_confirm_email_create' - USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_LINK = 'user_external_login_confirm_email_link' - USER_CONFIRM_MERGE = 'user_confirm_merge' - USER_CONFIRM_EMAIL = 'user_confirm_email' - USER_INITIAL_CONFIRM_EMAIL = 'user_initial_confirm_email' - USER_INVITE_DEFAULT = 'user_invite_default' - USER_PENDING_INVITE = 'user_pending_invite' - USER_FORWARD_INVITE = 'user_forward_invite' - USER_FORWARD_INVITE_REGISTERED = 'user_forward_invite_registered' - USER_INVITE_DRAFT_REGISTRATION = 'user_invite_draft_registration' - USER_INVITE_OSF_PREPRINT = 'user_invite_osf_preprint' - - # Node notifications - NODE_COMMENT = 'node_comments' - NODE_FILES_UPDATED = 'node_files_updated' - NODE_AFFILIATION_CHANGED = 'node_affiliation_changed' - NODE_REQUEST_ACCESS_SUBMITTED = 'node_access_request_submitted' - NODE_REQUEST_ACCESS_DENIED = 'node_request_access_denied' - NODE_FORK_COMPLETED = 'node_fork_completed' - NODE_FORK_FAILED = 'node_fork_failed' - NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST = 'node_request_institutional_access_request' - NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST = 'node_contributor_added_access_request' - NODE_PENDING_EMBARGO_ADMIN = 'node_pending_embargo_admin' - NODE_PENDING_EMBARGO_NON_ADMIN = 'node_pending_embargo_non_admin' - NODE_PENDING_RETRACTION_NON_ADMIN = 'node_pending_retraction_non_admin' - NODE_PENDING_RETRACTION_ADMIN = 'node_pending_retraction_admin' - NODE_PENDING_REGISTRATION_NON_ADMIN = 'node_pending_registration_non_admin' - NODE_PENDING_REGISTRATION_ADMIN = 'node_pending_registration_admin' - NODE_PENDING_EMBARGO_TERMINATION_NON_ADMIN = 'node_pending_embargo_termination_non_admin' - NODE_PENDING_EMBARGO_TERMINATION_ADMIN = 'node_pending_embargo_termination_admin' - - # Provider notifications - PROVIDER_NEW_PENDING_SUBMISSIONS = 'provider_new_pending_submissions' - PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION = 'provider_reviews_submission_confirmation' - PROVIDER_REVIEWS_MODERATOR_SUBMISSION_CONFIRMATION = 'provider_reviews_moderator_submission_confirmation' - PROVIDER_REVIEWS_WITHDRAWAL_REQUESTED = 'preprint_request_withdrawal_requested' - PROVIDER_REVIEWS_REJECT_CONFIRMATION = 'provider_reviews_reject_confirmation' - PROVIDER_REVIEWS_ACCEPT_CONFIRMATION = 'provider_reviews_accept_confirmation' - PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION = 'provider_reviews_resubmission_confirmation' - PROVIDER_REVIEWS_COMMENT_EDITED = 'provider_reviews_comment_edited' - PROVIDER_CONTRIBUTOR_ADDED_PREPRINT = 'provider_contributor_added_preprint' - PROVIDER_CONFIRM_EMAIL_MODERATION = 'provider_confirm_email_moderation' - PROVIDER_MODERATOR_ADDED = 'provider_moderator_added' - PROVIDER_CONFIRM_EMAIL_PREPRINTS = 'provider_confirm_email_preprints' - PROVIDER_USER_INVITE_PREPRINT = 'provider_user_invite_preprint' - - # Preprint notifications - PREPRINT_REQUEST_WITHDRAWAL_APPROVED = 'preprint_request_withdrawal_approved' - PREPRINT_REQUEST_WITHDRAWAL_DECLINED = 'preprint_request_withdrawal_declined' - PREPRINT_CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF = 'preprint_contributor_added_preprint_node_from_osf' - - # Collections Submission notifications - COLLECTION_SUBMISSION_REMOVED_ADMIN = 'collection_submission_removed_admin' - COLLECTION_SUBMISSION_REMOVED_MODERATOR = 'collection_submission_removed_moderator' - COLLECTION_SUBMISSION_REMOVED_PRIVATE = 'collection_submission_removed_private' - COLLECTION_SUBMISSION_SUBMITTED = 'collection_submission_submitted' - COLLECTION_SUBMISSION_ACCEPTED = 'collection_submission_accepted' - COLLECTION_SUBMISSION_REJECTED = 'collection_submission_rejected' - COLLECTION_SUBMISSION_CANCEL = 'collection_submission_cancel' - - # Schema Response notifications - SCHEMA_RESPONSE_REJECTED = 'schema_response_rejected' - SCHEMA_RESPONSE_APPROVED = 'schema_response_approved' - SCHEMA_RESPONSE_SUBMITTED = 'schema_response_submitted' - SCHEMA_RESPONSE_INITIATED = 'schema_response_initiated' - - REGISTRATION_BULK_UPLOAD_FAILURE_DUPLICATES = 'registration_bulk_upload_failure_duplicates' - - @property - def instance(self): - obj, created = NotificationType.objects.get_or_create(name=self.value) - return obj - - @classmethod - def user_types(cls): - return [member for member in cls if member.name.startswith('USER_')] - - @classmethod - def node_types(cls): - return [member for member in cls if member.name.startswith('NODE_')] - - @classmethod - def preprint_types(cls): - return [member for member in cls if member.name.startswith('PREPRINT_')] - - @classmethod - def provider_types(cls): - return [member for member in cls if member.name.startswith('PROVIDER_')] - - @classmethod - def schema_response_types(cls): - return [member for member in cls if member.name.startswith('SCHEMA_RESPONSE_')] - - @classmethod - def desk_types(cls): - return [member for member in cls if member.name.startswith('DESK_')] - - name: str = models.CharField(max_length=255, unique=True) - notification_freq: str = models.CharField( - max_length=32, - choices=FrequencyChoices.choices(), - default=FrequencyChoices.INSTANTLY.value, - ) - - object_content_type = models.ForeignKey( - ContentType, - on_delete=models.SET_NULL, - null=True, - blank=True, - help_text='Content type for subscribed objects. Null means global event.' - ) - - template: str = models.TextField( - help_text='Template used to render the event_info. Supports Django template syntax.' - ) - - def clean(self): - try: - Template(self.template) - except TemplateSyntaxError as exc: - raise ValidationError({'template': f'Invalid template: {exc}'}) - - def emit(self, user, subscribed_object=None, event_context=None): - """Emit a notification to a user by creating Notification and NotificationSubscription objects. - - Args: - user (OSFUser): The recipient of the notification. - subscribed_object (optional): The object the subscription is related to. - event_context (dict, optional): Context for rendering the notification template. - """ - subscription, created = NotificationSubscription.objects.get_or_create( - notification_type=self, - user=user, - content_type=ContentType.objects.get_for_model(subscribed_object) if subscribed_object else None, - object_id=subscribed_object.pk if subscribed_object else None, - defaults={'message_frequency': self.notification_freq}, - ) - if subscription.message_frequency == 'instantly': - Notification.objects.create( - subscription=subscription, - event_context=event_context - ).send() - - def add_user_to_subscription(self, user, *args, **kwargs): - """ - """ - provider = kwargs.pop('provider', None) - node = kwargs.pop('node', None) - data = {} - if subscribed_object := provider or node: - data = { - 'object_id': subscribed_object.id, - 'content_type_id': ContentType.objects.get_for_model(subscribed_object).id, - } - - notification, created = NotificationSubscription.objects.get_or_create( - user=user, - notification_type=self, - **data, - ) - return notification - - def remove_user_from_subscription(self, user): - """ - """ - notification, _ = NotificationSubscription.objects.update_or_create( - user=user, - notification_type=self, - defaults={'message_frequency': FrequencyChoices.NONE.value} - ) - - def __str__(self) -> str: - return self.name - - class Meta: - verbose_name = 'Notification Type' - verbose_name_plural = 'Notification Types' - - -class NotificationSubscription(BaseModel): - notification_type: NotificationType = models.ForeignKey( - NotificationType, - on_delete=models.CASCADE, - null=False - ) - user = models.ForeignKey('osf.OSFUser', on_delete=models.CASCADE, related_name='subscriptions') - message_frequency: str = models.CharField(max_length=32) - - content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) - object_id = models.CharField(max_length=255, null=True, blank=True) - subscribed_object = GenericForeignKey('content_type', 'object_id') - - def clean(self): - ct = self.notification_type.object_content_type - - if ct: - if self.content_type != ct: - raise ValidationError('Subscribed object must match type\'s content_type.') - if not self.object_id: - raise ValidationError('Subscribed object ID is required.') - else: - if self.content_type or self.object_id: - raise ValidationError('Global subscriptions must not have an object.') - - if self.message_frequency not in self.notification_type.notification_freq: - raise ValidationError(f'{self.message_frequency!r} is not allowed for {self.notification_type.name!r}.') - - def __str__(self) -> str: - return f'<{self.user} via {self.subscribed_object} subscribes to {self.notification_type.name} ({self.message_frequency})>' - - class Meta: - verbose_name = 'Notification Subscription' - verbose_name_plural = 'Notification Subscriptions' - - def emit(self, user, subscribed_object=None, event_context=None): - """Emit a notification to a user by creating Notification and NotificationSubscription objects. - - Args: - user (OSFUser): The recipient of the notification. - subscribed_object (optional): The object the subscription is related to. - event_context (dict, optional): Context for rendering the notification template. - """ - if self.message_frequency == 'instantly': - Notification.objects.create( - subscription=self, - event_context=event_context - ).send() - else: - Notification.objects.create( - subscription=self, - event_context=event_context - ) - - @property - def absolute_api_v2_url(self): - from api.base.utils import absolute_reverse - return absolute_reverse('institutions:institution-detail', kwargs={'institution_id': self._id, 'version': 'v2'}) - - from django.contrib.contenttypes.models import ContentType - - @property - def _id(self): - """ - Legacy subscription id for API compatibility. - Provider: _ - User/global: _global_ - Node/etc: _ - """ - # Safety checks - event = self.notification_type.name - ct = self.notification_type.object_content_type - match getattr(ct, 'model', None): - case 'preprintprovider' | 'collectionprovider' | 'registrationprovider': - # Providers: use subscribed_object._id (which is the provider short name, e.g. 'mindrxiv') - return f'{self.subscribed_object._id}_new_pending_submissions' - case 'node' | 'collection' | 'preprint': - # Node-like objects: use object_id (guid) - return f'{self.subscribed_object._id}_{event}' - case 'osfuser' | 'user' | None: - # Global: _global - return f'{self.user._id}_global' - case _: - raise NotImplementedError() - - class Notification(models.Model): subscription = models.ForeignKey( - NotificationSubscription, + 'NotificationSubscription', on_delete=models.CASCADE, related_name='notifications' ) diff --git a/osf/models/notification_subscription.py b/osf/models/notification_subscription.py new file mode 100644 index 00000000000..b2ecb3c0b99 --- /dev/null +++ b/osf/models/notification_subscription.py @@ -0,0 +1,102 @@ +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError + +from .base import BaseModel + + +class NotificationSubscription(BaseModel): + notification_type = models.ForeignKey( + 'NotificationType', + on_delete=models.CASCADE, + null=True + ) + user = models.ForeignKey( + 'osf.OSFUser', + null=True, + on_delete=models.CASCADE, + related_name='subscriptions' + ) + message_frequency: str = models.CharField( + max_length=500, + null=True + ) + + content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE) + object_id = models.CharField(max_length=255, null=True, blank=True) + subscribed_object = GenericForeignKey('content_type', 'object_id') + + def clean(self): + ct = self.notification_type.object_content_type + + if ct: + if self.content_type != ct: + raise ValidationError('Subscribed object must match type\'s content_type.') + if not self.object_id: + raise ValidationError('Subscribed object ID is required.') + else: + if self.content_type or self.object_id: + raise ValidationError('Global subscriptions must not have an object.') + from . import NotificationType + + allowed_freqs = self.notification_type.notification_interval_choices or NotificationType.DEFAULT_FREQUENCY_CHOICES + if self.message_frequency not in allowed_freqs: + raise ValidationError(f'{self.message_frequency!r} is not allowed for {self.notification_type.name!r}.') + + def __str__(self) -> str: + return f'<{self.user} via {self.subscribed_object} subscribes to {self.notification_type.name} ({self.message_frequency})>' + + class Meta: + verbose_name = 'Notification Subscription' + verbose_name_plural = 'Notification Subscriptions' + + def emit(self, user, subscribed_object=None, event_context=None): + """Emit a notification to a user by creating Notification and NotificationSubscription objects. + + Args: + user (OSFUser): The recipient of the notification. + subscribed_object (optional): The object the subscription is related to. + event_context (dict, optional): Context for rendering the notification template. + """ + from . import Notification + + if self.message_frequency == 'instantly': + Notification.objects.create( + subscription=self, + event_context=event_context + ).send() + else: + Notification.objects.create( + subscription=self, + event_context=event_context + ) + + @property + def absolute_api_v2_url(self): + from api.base.utils import absolute_reverse + return absolute_reverse('institutions:institution-detail', kwargs={'institution_id': self._id, 'version': 'v2'}) + + @property + def _id(self): + """ + Legacy subscription id for API compatibility. + Provider: _ + User/global: _global_ + Node/etc: _ + """ + # Safety checks + event = self.notification_type.name + ct = self.notification_type.object_content_type + match getattr(ct, 'model', None): + case 'preprintprovider' | 'collectionprovider' | 'registrationprovider': + # Providers: use subscribed_object._id (which is the provider short name, e.g. 'mindrxiv') + return f'{self.subscribed_object._id}_new_pending_submissions' + case 'node' | 'collection' | 'preprint': + # Node-like objects: use object_id (guid) + return f'{self.subscribed_object._id}_{event}' + case 'osfuser' | 'user' | None: + # Global: _global + return f'{self.user._id}_global' + case _: + raise NotImplementedError() diff --git a/osf/models/notification_type.py b/osf/models/notification_type.py new file mode 100644 index 00000000000..c9b139b1fc1 --- /dev/null +++ b/osf/models/notification_type.py @@ -0,0 +1,247 @@ +from django.db import models +from django.contrib.postgres.fields import ArrayField +from django.contrib.contenttypes.models import ContentType + +from .notification_subscription import NotificationSubscription +from .notification import Notification +from enum import Enum + + +class FrequencyChoices(Enum): + NONE = 'none' + INSTANTLY = 'instantly' + DAILY = 'daily' + WEEKLY = 'weekly' + MONTHLY = 'monthly' + + @classmethod + def choices(cls): + return [(key.value, key.name.capitalize()) for key in cls] + +def get_default_frequency_choices(): + DEFAULT_FREQUENCY_CHOICES = ['none', 'instantly', 'daily', 'weekly', 'monthly'] + return DEFAULT_FREQUENCY_CHOICES.copy() + + +class NotificationType(models.Model): + + class Type(str, Enum): + # Desk notifications + DESK_REQUEST_EXPORT = 'desk_request_export' + DESK_REQUEST_DEACTIVATION = 'desk_request_deactivation' + DESK_OSF_SUPPORT_EMAIL = 'desk_osf_support_email' + DESK_REGISTRATION_BULK_UPLOAD_PRODUCT_OWNER = 'desk_registration_bulk_upload_product_owner' + DESK_USER_REGISTRATION_BULK_UPLOAD_UNEXPECTED_FAILURE = 'desk_user_registration_bulk_upload_unexpected_failure' + DESK_ARCHIVE_JOB_EXCEEDED = 'desk_archive_job_exceeded' + DESK_ARCHIVE_JOB_COPY_ERROR = 'desk_archive_job_copy_error' + DESK_ARCHIVE_JOB_FILE_NOT_FOUND = 'desk_archive_job_file_not_found' + DESK_ARCHIVE_JOB_UNCAUGHT_ERROR = 'desk_archive_job_uncaught_error' + + # User notifications + USER_PENDING_VERIFICATION = 'user_pending_verification' + USER_PENDING_VERIFICATION_REGISTERED = 'user_pending_verification_registered' + USER_STORAGE_CAP_EXCEEDED_ANNOUNCEMENT = 'user_storage_cap_exceeded_announcement' + USER_SPAM_BANNED = 'user_spam_banned' + USER_REQUEST_DEACTIVATION_COMPLETE = 'user_request_deactivation_complete' + USER_PRIMARY_EMAIL_CHANGED = 'user_primary_email_changed' + USER_INSTITUTION_DEACTIVATION = 'user_institution_deactivation' + USER_FORGOT_PASSWORD = 'user_forgot_password' + USER_FORGOT_PASSWORD_INSTITUTION = 'user_forgot_password_institution' + USER_REQUEST_EXPORT = 'user_request_export' + USER_CONTRIBUTOR_ADDED_OSF_PREPRINT = 'user_contributor_added_osf_preprint' + USER_CONTRIBUTOR_ADDED_DEFAULT = 'user_contributor_added_default' + USER_DUPLICATE_ACCOUNTS_OSF4I = 'user_duplicate_accounts_osf4i' + USER_EXTERNAL_LOGIN_LINK_SUCCESS = 'user_external_login_link_success' + USER_REGISTRATION_BULK_UPLOAD_FAILURE_ALL = 'user_registration_bulk_upload_failure_all' + USER_REGISTRATION_BULK_UPLOAD_SUCCESS_PARTIAL = 'user_registration_bulk_upload_success_partial' + USER_REGISTRATION_BULK_UPLOAD_SUCCESS_ALL = 'user_registration_bulk_upload_success_all' + USER_ADD_SSO_EMAIL_OSF4I = 'user_add_sso_email_osf4i' + USER_WELCOME_OSF4I = 'user_welcome_osf4i' + USER_ARCHIVE_JOB_EXCEEDED = 'user_archive_job_exceeded' + USER_ARCHIVE_JOB_COPY_ERROR = 'user_archive_job_copy_error' + USER_ARCHIVE_JOB_FILE_NOT_FOUND = 'user_archive_job_file_not_found' + USER_ARCHIVE_JOB_UNCAUGHT_ERROR = 'user_archive_job_uncaught_error' + USER_COMMENT_REPLIES = 'user_comment_replies' + USER_COMMENTS = 'user_comments' + USER_FILE_UPDATED = 'user_file_updated' + USER_COMMENT_MENTIONS = 'user_mentions' + USER_REVIEWS = 'user_reviews' + USER_PASSWORD_RESET = 'user_password_reset' + USER_CONTRIBUTOR_ADDED_DRAFT_REGISTRATION = 'user_contributor_added_draft_registration' + USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_CREATE = 'user_external_login_confirm_email_create' + USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_LINK = 'user_external_login_confirm_email_link' + USER_CONFIRM_MERGE = 'user_confirm_merge' + USER_CONFIRM_EMAIL = 'user_confirm_email' + USER_INITIAL_CONFIRM_EMAIL = 'user_initial_confirm_email' + USER_INVITE_DEFAULT = 'user_invite_default' + USER_PENDING_INVITE = 'user_pending_invite' + USER_FORWARD_INVITE = 'user_forward_invite' + USER_FORWARD_INVITE_REGISTERED = 'user_forward_invite_registered' + USER_INVITE_DRAFT_REGISTRATION = 'user_invite_draft_registration' + USER_INVITE_OSF_PREPRINT = 'user_invite_osf_preprint' + + # Node notifications + NODE_COMMENT = 'node_comments' + NODE_FILES_UPDATED = 'node_files_updated' + NODE_AFFILIATION_CHANGED = 'node_affiliation_changed' + NODE_REQUEST_ACCESS_SUBMITTED = 'node_access_request_submitted' + NODE_REQUEST_ACCESS_DENIED = 'node_request_access_denied' + NODE_FORK_COMPLETED = 'node_fork_completed' + NODE_FORK_FAILED = 'node_fork_failed' + NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST = 'node_request_institutional_access_request' + NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST = 'node_contributor_added_access_request' + NODE_PENDING_EMBARGO_ADMIN = 'node_pending_embargo_admin' + NODE_PENDING_EMBARGO_NON_ADMIN = 'node_pending_embargo_non_admin' + NODE_PENDING_RETRACTION_NON_ADMIN = 'node_pending_retraction_non_admin' + NODE_PENDING_RETRACTION_ADMIN = 'node_pending_retraction_admin' + NODE_PENDING_REGISTRATION_NON_ADMIN = 'node_pending_registration_non_admin' + NODE_PENDING_REGISTRATION_ADMIN = 'node_pending_registration_admin' + NODE_PENDING_EMBARGO_TERMINATION_NON_ADMIN = 'node_pending_embargo_termination_non_admin' + NODE_PENDING_EMBARGO_TERMINATION_ADMIN = 'node_pending_embargo_termination_admin' + + # Provider notifications + PROVIDER_NEW_PENDING_SUBMISSIONS = 'provider_new_pending_submissions' + PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION = 'provider_reviews_submission_confirmation' + PROVIDER_REVIEWS_MODERATOR_SUBMISSION_CONFIRMATION = 'provider_reviews_moderator_submission_confirmation' + PROVIDER_REVIEWS_WITHDRAWAL_REQUESTED = 'preprint_request_withdrawal_requested' + PROVIDER_REVIEWS_REJECT_CONFIRMATION = 'provider_reviews_reject_confirmation' + PROVIDER_REVIEWS_ACCEPT_CONFIRMATION = 'provider_reviews_accept_confirmation' + PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION = 'provider_reviews_resubmission_confirmation' + PROVIDER_REVIEWS_COMMENT_EDITED = 'provider_reviews_comment_edited' + PROVIDER_CONTRIBUTOR_ADDED_PREPRINT = 'provider_contributor_added_preprint' + PROVIDER_CONFIRM_EMAIL_MODERATION = 'provider_confirm_email_moderation' + PROVIDER_MODERATOR_ADDED = 'provider_moderator_added' + PROVIDER_CONFIRM_EMAIL_PREPRINTS = 'provider_confirm_email_preprints' + PROVIDER_USER_INVITE_PREPRINT = 'provider_user_invite_preprint' + + # Preprint notifications + PREPRINT_REQUEST_WITHDRAWAL_APPROVED = 'preprint_request_withdrawal_approved' + PREPRINT_REQUEST_WITHDRAWAL_DECLINED = 'preprint_request_withdrawal_declined' + PREPRINT_CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF = 'preprint_contributor_added_preprint_node_from_osf' + + # Collections Submission notifications + COLLECTION_SUBMISSION_REMOVED_ADMIN = 'collection_submission_removed_admin' + COLLECTION_SUBMISSION_REMOVED_MODERATOR = 'collection_submission_removed_moderator' + COLLECTION_SUBMISSION_REMOVED_PRIVATE = 'collection_submission_removed_private' + COLLECTION_SUBMISSION_SUBMITTED = 'collection_submission_submitted' + COLLECTION_SUBMISSION_ACCEPTED = 'collection_submission_accepted' + COLLECTION_SUBMISSION_REJECTED = 'collection_submission_rejected' + COLLECTION_SUBMISSION_CANCEL = 'collection_submission_cancel' + + # Schema Response notifications + SCHEMA_RESPONSE_REJECTED = 'schema_response_rejected' + SCHEMA_RESPONSE_APPROVED = 'schema_response_approved' + SCHEMA_RESPONSE_SUBMITTED = 'schema_response_submitted' + SCHEMA_RESPONSE_INITIATED = 'schema_response_initiated' + + REGISTRATION_BULK_UPLOAD_FAILURE_DUPLICATES = 'registration_bulk_upload_failure_duplicates' + + @property + def instance(self): + obj, created = NotificationType.objects.get_or_create(name=self.value) + return obj + + @classmethod + def user_types(cls): + return [member for member in cls if member.name.startswith('USER_')] + + @classmethod + def node_types(cls): + return [member for member in cls if member.name.startswith('NODE_')] + + @classmethod + def preprint_types(cls): + return [member for member in cls if member.name.startswith('PREPRINT_')] + + @classmethod + def provider_types(cls): + return [member for member in cls if member.name.startswith('PROVIDER_')] + + @classmethod + def schema_response_types(cls): + return [member for member in cls if member.name.startswith('SCHEMA_RESPONSE_')] + + @classmethod + def desk_types(cls): + return [member for member in cls if member.name.startswith('DESK_')] + + notification_interval_choices = ArrayField( + base_field=models.CharField(max_length=32), + default=get_default_frequency_choices, + blank=True + ) + + name: str = models.CharField(max_length=255, unique=True) + + object_content_type = models.ForeignKey( + ContentType, + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text='Content type for subscribed objects. Null means global event.' + ) + + template: str = models.TextField( + help_text='Template used to render the event_info. Supports Django template syntax.' + ) + subject: str = models.TextField( + blank=True, + null=True, + help_text='Template used to render the subject line of email. Supports Django template syntax.' + ) + + def emit(self, user, subscribed_object=None, event_context=None): + """Emit a notification to a user by creating Notification and NotificationSubscription objects. + + Args: + user (OSFUser): The recipient of the notification. + subscribed_object (optional): The object the subscription is related to. + event_context (dict, optional): Context for rendering the notification template. + """ + subscription, created = NotificationSubscription.objects.get_or_create( + notification_type=self, + user=user, + content_type=ContentType.objects.get_for_model(subscribed_object) if subscribed_object else None, + object_id=subscribed_object.pk if subscribed_object else None, + defaults={'message_frequency': self.notification_freq}, + ) + if subscription.message_frequency == 'instantly': + Notification.objects.create( + subscription=subscription, + event_context=event_context + ).send() + + def add_user_to_subscription(self, user, *args, **kwargs): + """ + """ + provider = kwargs.pop('provider', None) + node = kwargs.pop('node', None) + data = {} + if subscribed_object := provider or node: + data = { + 'object_id': subscribed_object.id, + 'content_type_id': ContentType.objects.get_for_model(subscribed_object).id, + } + + notification, created = NotificationSubscription.objects.get_or_create( + user=user, + notification_type=self, + **data, + ) + return notification + + def remove_user_from_subscription(self, user): + """ + """ + notification, _ = NotificationSubscription.objects.update_or_create( + user=user, + notification_type=self, + defaults={'message_frequency': FrequencyChoices.NONE.value} + ) + + def __str__(self) -> str: + return self.name + + class Meta: + verbose_name = 'Notification Type' + verbose_name_plural = 'Notification Types' From f2e5309453d9284b6dd3d5fa9c00c0da6bcec317 Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Tue, 8 Jul 2025 11:02:16 -0400 Subject: [PATCH 7/9] clean-up tests and pass frequency data properly --- api/subscriptions/serializers.py | 3 ++- .../subscriptions/views/test_subscriptions_detail.py | 3 +-- osf/models/notification_subscription.py | 7 +++---- osf/models/notification_type.py | 11 +++++++---- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api/subscriptions/serializers.py b/api/subscriptions/serializers.py index d37a8342564..ede0782ae65 100644 --- a/api/subscriptions/serializers.py +++ b/api/subscriptions/serializers.py @@ -37,7 +37,8 @@ def get_absolute_url(self, obj): def update(self, instance, validated_data): user = self.context['request'].user - frequency = validated_data.get('frequency') + frequency = validated_data.get('frequency') or 'none' + instance.message_frequency = frequency if frequency != 'none' and instance.content_type == ContentType.objects.get_for_model(Node): node = Node.objects.get( diff --git a/api_tests/subscriptions/views/test_subscriptions_detail.py b/api_tests/subscriptions/views/test_subscriptions_detail.py index 2d91e6b1083..0e2fa22b119 100644 --- a/api_tests/subscriptions/views/test_subscriptions_detail.py +++ b/api_tests/subscriptions/views/test_subscriptions_detail.py @@ -23,7 +23,6 @@ def notification(self, user): @pytest.fixture() def url(self, notification): - print('_id', notification._id) return f'/{API_BASE}subscriptions/{notification._id}/' @pytest.fixture() @@ -116,7 +115,7 @@ def test_subscription_detail_invalid_payload_400( expect_errors=True ) assert res.status_code == 400 - assert res.json['errors'][0]['detail'] == '"invalid-frequency" is not a valid choice.' + assert res.json['errors'][0]['detail'] == ('"invalid-frequency" is not a valid choice.') def test_subscription_detail_patch_invalid_notification_id_no_user( self, app, user, user_no_auth, notification, url, url_invalid, payload, payload_invalid diff --git a/osf/models/notification_subscription.py b/osf/models/notification_subscription.py index b2ecb3c0b99..a1c9467b50e 100644 --- a/osf/models/notification_subscription.py +++ b/osf/models/notification_subscription.py @@ -2,6 +2,8 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from osf.models.notification_type import get_default_frequency_choices +from osf.models.notification import Notification from .base import BaseModel @@ -38,9 +40,8 @@ def clean(self): else: if self.content_type or self.object_id: raise ValidationError('Global subscriptions must not have an object.') - from . import NotificationType - allowed_freqs = self.notification_type.notification_interval_choices or NotificationType.DEFAULT_FREQUENCY_CHOICES + allowed_freqs = self.notification_type.notification_interval_choices or get_default_frequency_choices() if self.message_frequency not in allowed_freqs: raise ValidationError(f'{self.message_frequency!r} is not allowed for {self.notification_type.name!r}.') @@ -59,8 +60,6 @@ def emit(self, user, subscribed_object=None, event_context=None): subscribed_object (optional): The object the subscription is related to. event_context (dict, optional): Context for rendering the notification template. """ - from . import Notification - if self.message_frequency == 'instantly': Notification.objects.create( subscription=self, diff --git a/osf/models/notification_type.py b/osf/models/notification_type.py index c9b139b1fc1..eb41405a8c4 100644 --- a/osf/models/notification_type.py +++ b/osf/models/notification_type.py @@ -2,8 +2,7 @@ from django.contrib.postgres.fields import ArrayField from django.contrib.contenttypes.models import ContentType -from .notification_subscription import NotificationSubscription -from .notification import Notification +from osf.models.notification import Notification from enum import Enum @@ -190,7 +189,7 @@ def desk_types(cls): help_text='Template used to render the subject line of email. Supports Django template syntax.' ) - def emit(self, user, subscribed_object=None, event_context=None): + def emit(self, user, subscribed_object=None, message_frequency=None, event_context=None): """Emit a notification to a user by creating Notification and NotificationSubscription objects. Args: @@ -198,12 +197,13 @@ def emit(self, user, subscribed_object=None, event_context=None): subscribed_object (optional): The object the subscription is related to. event_context (dict, optional): Context for rendering the notification template. """ + from osf.models.notification_subscription import NotificationSubscription subscription, created = NotificationSubscription.objects.get_or_create( notification_type=self, user=user, content_type=ContentType.objects.get_for_model(subscribed_object) if subscribed_object else None, object_id=subscribed_object.pk if subscribed_object else None, - defaults={'message_frequency': self.notification_freq}, + defaults={'message_frequency': message_frequency}, ) if subscription.message_frequency == 'instantly': Notification.objects.create( @@ -214,6 +214,8 @@ def emit(self, user, subscribed_object=None, event_context=None): def add_user_to_subscription(self, user, *args, **kwargs): """ """ + from osf.models.notification_subscription import NotificationSubscription + provider = kwargs.pop('provider', None) node = kwargs.pop('node', None) data = {} @@ -233,6 +235,7 @@ def add_user_to_subscription(self, user, *args, **kwargs): def remove_user_from_subscription(self, user): """ """ + from osf.models.notification_subscription import NotificationSubscription notification, _ = NotificationSubscription.objects.update_or_create( user=user, notification_type=self, From 0471b76812e17d978459a00b62c7c210e5b91a30 Mon Sep 17 00:00:00 2001 From: John Tordoff Date: Tue, 8 Jul 2025 14:41:17 -0400 Subject: [PATCH 8/9] update management commands and tests for notification migration --- notifications.yaml | 124 ++++++++++++++++ .../commands/migrate_notifications.py | 115 +++++++++++++++ ...tificationsubscription_options_and_more.py | 2 +- osf/models/notification_type.py | 2 +- .../test_migrate_notifications.py | 132 ++++++++++++++++++ website/settings/defaults.py | 1 + 6 files changed, 374 insertions(+), 2 deletions(-) create mode 100644 notifications.yaml create mode 100644 osf/management/commands/migrate_notifications.py create mode 100644 osf_tests/management_commands/test_migrate_notifications.py diff --git a/notifications.yaml b/notifications.yaml new file mode 100644 index 00000000000..a86820be248 --- /dev/null +++ b/notifications.yaml @@ -0,0 +1,124 @@ +# This file contains the configuration for our notification system using the NotificationType object, this is intended to +# exist as a simple declarative list of NotificationTypes and their attributes. Every notification sent by OSF should be +# represented here for bussiness logic dnd metrics reasons. + +# Workflow: +# 1. Add a new notification template +# 2. Add a entry here with the desired notification types +# 3. Add name tp Enum osf.notification.NotificationType.Type +# 4. Use the emit method to send or subscribe the notification for immediate deliver or periodic digest. +notification_types: + #### GLOBAL (User Notifications) + - name: user_pending_verification_registered + __docs__: ... + object_content_type_model_name: osfuser + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + #### PROVIDER + - name: new_pending_submissions + __docs__: ... + object_content_type_model_name: abstractprovider + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: new_pending_withdraw_requests + __docs__: ... + object_content_type_model_name: abstractprovider + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + #### NODE + - name: file_updated + __docs__: ... + object_content_type_model_name: abstractnode + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: wiki_updated + __docs__: ... + object_content_type_model_name: abstractnode + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: node_contributor_added_access_request + __docs__: ... + object_content_type_model_name: abstractnode + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: node_request_institutional_access_request + __docs__: ... + object_content_type_model_name: abstractnode + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + + #### PREPRINT + - name: pending_retraction_admin + __docs__: ... + object_content_type_model_name: preprint + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + + #### SUPPORT + - name: crossref_error + __docs__: ... + object_content_type_model_name: abstractnode + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + #### Collection Submissions + - name: collection_submission_removed_moderator + __docs__: ... + object_content_type_model_name: collectionsubmission + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: collection_submission_removed_private + __docs__: ... + object_content_type_model_name: collectionsubmission + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: collection_submission_removed_admin + __docs__: ... + object_content_type_model_name: collectionsubmission + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: collection_submission_submitted + __docs__: ... + object_content_type_model_name: collectionsubmission + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: collection_submission_cancel + __docs__: ... + object_content_type_model_name: collectionsubmission + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: collection_submission_accepted + __docs__: ... + object_content_type_model_name: collectionsubmission + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: collection_submission_rejected + __docs__: ... + object_content_type_model_name: collectionsubmission + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + #### DESK + - name: desk_archive_job_exceeded + __docs__: Archive job failed due to size exceeded. Sent to support desk. + object_content_type_model_name: desk + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: desk_archive_job_copy_error + __docs__: Archive job failed due to copy error. Sent to support desk. + object_content_type_model_name: desk + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: desk_archive_job_file_not_found + __docs__: Archive job failed because files were not found. Sent to support desk. + object_content_type_model_name: desk + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: desk_archive_job_uncaught_error + __docs__: Archive job failed due to an uncaught error. Sent to support desk. + object_content_type_model_name: desk + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly + - name: desk_osf_support_email + __docs__: ... + object_content_type_model_name: desk + template: 'website/templates/emails/new_pending_submissions.html.mako' + notification_freq_default: instantly diff --git a/osf/management/commands/migrate_notifications.py b/osf/management/commands/migrate_notifications.py new file mode 100644 index 00000000000..8b7c1fe2a5e --- /dev/null +++ b/osf/management/commands/migrate_notifications.py @@ -0,0 +1,115 @@ +import yaml +from django.apps import apps +from website import settings + +import logging +from django.contrib.contenttypes.models import ContentType +from osf.models import NotificationType, NotificationSubscription +from osf.models.notifications import NotificationSubscriptionLegacy +from django.core.management.base import BaseCommand +from django.db import transaction + +logger = logging.getLogger(__name__) + +FREQ_MAP = { + 'none': 'none', + 'email_digest': 'weekly', + 'email_transactional': 'instantly', +} + +def migrate_legacy_notification_subscriptions(*args, **kwargs): + """ + Migrate legacy NotificationSubscription data to new notifications app. + """ + logger.info('Beginning legacy notification subscription migration...') + + PROVIDER_BASED_LEGACY_NOTIFICATION_TYPES = [ + 'new_pending_submissions', 'new_pending_withdraw_requests' + ] + + for legacy in NotificationSubscriptionLegacy.objects.all(): + event_name = legacy.event_name + if event_name in PROVIDER_BASED_LEGACY_NOTIFICATION_TYPES: + subscribed_object = legacy.provider + elif subscribed_object := legacy.node: + pass + elif subscribed_object := legacy.user: + pass + else: + raise NotImplementedError(f'Invalid Notification id {event_name}') + content_type = ContentType.objects.get_for_model(subscribed_object.__class__) + subscription, _ = NotificationSubscription.objects.update_or_create( + notification_type=NotificationType.objects.get(name=event_name), + user=legacy.user, + content_type=content_type, + object_id=subscribed_object.id, + defaults={ + 'user': legacy.user, + 'message_frequency': ( + ('weekly' if legacy.email_digest.exists() else 'none'), + 'instantly' if legacy.email_transactional.exists() else 'none' + ), + 'content_type': content_type, + 'object_id': subscribed_object.id, + } + ) + logger.info(f'Created NotificationType "{event_name}" with content_type {content_type}') + + +def update_notification_types(*args, **kwargs): + + with open(settings.NOTIFICATION_TYPES_YAML) as stream: + notification_types = yaml.safe_load(stream) + for notification_type in notification_types['notification_types']: + notification_type.pop('__docs__') + object_content_type_model_name = notification_type.pop('object_content_type_model_name') + notification_freq = notification_type.pop('notification_freq_default') + + if object_content_type_model_name == 'desk': + content_type = None + elif object_content_type_model_name == 'osfuser': + OSFUser = apps.get_model('osf', 'OSFUser') + content_type = ContentType.objects.get_for_model(OSFUser) + elif object_content_type_model_name == 'preprint': + Preprint = apps.get_model('osf', 'Preprint') + content_type = ContentType.objects.get_for_model(Preprint) + elif object_content_type_model_name == 'collectionsubmission': + CollectionSubmission = apps.get_model('osf', 'CollectionSubmission') + content_type = ContentType.objects.get_for_model(CollectionSubmission) + elif object_content_type_model_name == 'abstractprovider': + AbstractProvider = apps.get_model('osf', 'abstractprovider') + content_type = ContentType.objects.get_for_model(AbstractProvider) + elif object_content_type_model_name == 'osfuser': + OSFUser = apps.get_model('osf', 'OSFUser') + content_type = ContentType.objects.get_for_model(OSFUser) + else: + try: + content_type = ContentType.objects.get( + app_label='osf', + model=object_content_type_model_name + ) + except ContentType.DoesNotExist: + raise ValueError(f'No content type for osf.{object_content_type_model_name}') + + with open(notification_type['template']) as stream: + template = stream.read() + + notification_types['template'] = template + notification_types['notification_freq'] = notification_freq + nt, _ = NotificationType.objects.update_or_create( + name=notification_type['name'], + defaults=notification_type, + ) + nt.object_content_type = content_type + nt.save() + + +class Command(BaseCommand): + help = 'Migrate legacy NotificationSubscriptionLegacy objects to new Notification app models.' + + def handle(self, *args, **options): + with transaction.atomic(): + update_notification_types(args, options) + + with transaction.atomic(): + migrate_legacy_notification_subscriptions(args, options) diff --git a/osf/migrations/0032_alter_notificationsubscription_options_and_more.py b/osf/migrations/0032_alter_notificationsubscription_options_and_more.py index faa9ebdca19..b4f273108d5 100644 --- a/osf/migrations/0032_alter_notificationsubscription_options_and_more.py +++ b/osf/migrations/0032_alter_notificationsubscription_options_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.13 on 2025-07-07 14:24 +# Generated by Django 4.2.13 on 2025-07-08 17:07 from django.conf import settings import django.contrib.postgres.fields diff --git a/osf/models/notification_type.py b/osf/models/notification_type.py index eb41405a8c4..9b36d20e93a 100644 --- a/osf/models/notification_type.py +++ b/osf/models/notification_type.py @@ -170,7 +170,7 @@ def desk_types(cls): blank=True ) - name: str = models.CharField(max_length=255, unique=True) + name: str = models.CharField(max_length=255, unique=True, null=False, blank=False) object_content_type = models.ForeignKey( ContentType, diff --git a/osf_tests/management_commands/test_migrate_notifications.py b/osf_tests/management_commands/test_migrate_notifications.py new file mode 100644 index 00000000000..f303ec3f996 --- /dev/null +++ b/osf_tests/management_commands/test_migrate_notifications.py @@ -0,0 +1,132 @@ +import pytest +from django.contrib.contenttypes.models import ContentType + +from osf.models import Node, RegistrationProvider +from osf_tests.factories import ( + AuthUserFactory, + PreprintProviderFactory, + ProjectFactory, +) +from osf.models import ( + NotificationType, + NotificationSubscription, + NotificationSubscriptionLegacy +) +from osf.management.commands.migrate_notifications import ( + migrate_legacy_notification_subscriptions, + update_notification_types +) + +@pytest.mark.django_db +class TestNotificationSubscriptionMigration: + + @pytest.fixture(autouse=True) + def notification_types(self): + return update_notification_types() + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def users(self): + return { + 'none': AuthUserFactory(), + 'digest': AuthUserFactory(), + 'transactional': AuthUserFactory(), + } + + @pytest.fixture() + def provider(self): + return PreprintProviderFactory() + + @pytest.fixture() + def provider2(self): + return PreprintProviderFactory() + + @pytest.fixture() + def node(self): + return ProjectFactory() + + def create_legacy_sub(self, event_name, users, user=None, provider=None, node=None): + legacy = NotificationSubscriptionLegacy.objects.create( + _id=f'{(provider or node)._id}_{event_name}', + user=user, + event_name=event_name, + provider=provider, + node=node + ) + legacy.none.add(users['none']) + legacy.email_digest.add(users['digest']) + legacy.email_transactional.add(users['transactional']) + return legacy + + def test_migrate_provider_subscription(self, user, provider, provider2): + NotificationSubscriptionLegacy.objects.get( + event_name='new_pending_submissions', + provider=provider + ) + NotificationSubscriptionLegacy.objects.get( + event_name='new_pending_submissions', + provider=provider2 + ) + NotificationSubscriptionLegacy.objects.get( + event_name='new_pending_submissions', + provider=RegistrationProvider.get_default() + ) + migrate_legacy_notification_subscriptions() + + subs = NotificationSubscription.objects.filter(notification_type__name='new_pending_submissions') + assert subs.count() == 3 + assert subs.get( + notification_type__name='new_pending_submissions', + object_id=provider.id, + content_type=ContentType.objects.get_for_model(provider.__class__) + ) + assert subs.get( + notification_type__name='new_pending_submissions', + object_id=provider2.id, + content_type=ContentType.objects.get_for_model(provider2.__class__) + ) + + def test_migrate_node_subscription(self, users, user, node): + self.create_legacy_sub('wiki_updated', users, user=user, node=node) + + migrate_legacy_notification_subscriptions() + + nt = NotificationType.objects.get(name='wiki_updated') + assert nt.object_content_type == ContentType.objects.get_for_model(Node) + + subs = NotificationSubscription.objects.filter(notification_type=nt) + assert subs.count() == 1 + + for sub in subs: + assert sub.subscribed_object == node + + def test_multiple_subscriptions_different_types(self, users, user, provider, node): + assert not NotificationSubscription.objects.filter(user=user) + self.create_legacy_sub('wiki_updated', users, user=user, node=node) + migrate_legacy_notification_subscriptions() + assert NotificationSubscription.objects.get(user=user).notification_type.name == 'wiki_updated' + assert NotificationSubscription.objects.get(notification_type__name='wiki_updated', user=user) + + def test_idempotent_migration(self, users, user, node, provider): + self.create_legacy_sub('file_updated', users, user=user, node=node) + migrate_legacy_notification_subscriptions() + migrate_legacy_notification_subscriptions() + assert NotificationSubscription.objects.get( + user=user, + object_id=node.id, + content_type=ContentType.objects.get_for_model(node.__class__), + notification_type__name='file_updated' + ) + + def test_errors_invalid_subscription(self, users): + legacy = NotificationSubscriptionLegacy.objects.create( + _id='broken', + event_name='invalid_event' + ) + legacy.none.add(users['none']) + + with pytest.raises(NotImplementedError): + migrate_legacy_notification_subscriptions() diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 66380d75fcc..64081235ec7 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -175,6 +175,7 @@ def parent_dir(path): MAILCHIMP_LIST_MAP = { MAILCHIMP_GENERAL_LIST: '123', } +NOTIFICATION_TYPES_YAML = 'notifications.yaml' #Triggered emails OSF_HELP_LIST = 'Open Science Framework Help' From 56b1f756768fab1bf1b3abd1c1d61a6c4160bae6 Mon Sep 17 00:00:00 2001 From: Ostap Zherebetskyi Date: Mon, 30 Jun 2025 17:31:05 +0300 Subject: [PATCH 9/9] Add notification type methods to EmailApprovableSanction and subclasses --- osf/models/sanctions.py | 93 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/osf/models/sanctions.py b/osf/models/sanctions.py index 6d8b904b4b9..308d5c82a9f 100644 --- a/osf/models/sanctions.py +++ b/osf/models/sanctions.py @@ -20,6 +20,7 @@ from osf.utils import tokens from osf.utils.machines import ApprovalsMachine from osf.utils.workflows import ApprovalStates, SanctionTypes +from osf.models import NotificationType VIEW_PROJECT_URL_TEMPLATE = osf_settings.DOMAIN + '{node_id}/' @@ -375,6 +376,12 @@ def _format_or_empty(template, context): return template.format(**context) return '' + def _get_authoriser_notification_type(self): + return None + + def _get_non_authoriser_notification_type(self): + return None + def _view_url(self, user_id, node): return self._format_or_empty(self.VIEW_URL_TEMPLATE, self._view_url_context(user_id, node)) @@ -412,6 +419,13 @@ def _notify_authorizer(self, authorizer, node): else: raise NotImplementedError() + try: + notification_type = self._get_authoriser_notification_type() + except NotificationType.DoesNotExist: + raise NotImplementedError() + if notification_type: + notification_type.emit(authorizer, context=context) + def _notify_non_authorizer(self, user, node): context = self._email_template_context(user, node) if self.NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE: @@ -420,6 +434,9 @@ def _notify_non_authorizer(self, user, node): else: raise NotImplementedError + if notification_type := self._get_non_authoriser_notification_type(): + notification_type.emit(user, context=context) + def ask(self, group): """ :param list group: List of (user, node) tuples containing contributors to notify about the @@ -470,6 +487,9 @@ class Embargo(SanctionCallbackMixin, EmailApprovableSanction): AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_ADMIN NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_embargo_admin' + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_embargo_non_admin' + VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' REJECT_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -502,6 +522,22 @@ def embargo_end_date(self): def pending_registration(self): return not self.for_existing_registration and self.is_pending_approval + def _get_authoriser_notification_type(self): + notification_type = NotificationType.objects.filter(name=self.AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + + def _get_non_authoriser_notification_type(self): + notification_type = NotificationType.objects.get(name=self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + def _get_registration(self): return self.registrations.first() @@ -650,6 +686,9 @@ class Retraction(EmailApprovableSanction): AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_ADMIN NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_retraction_admin' + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_retraction_non_admin' + VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' REJECT_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -658,6 +697,22 @@ class Retraction(EmailApprovableSanction): justification = models.CharField(max_length=2048, null=True, blank=True) date_retracted = NonNaiveDateTimeField(null=True, blank=True) + def _get_authoriser_notification_type(self): + notification_type = NotificationType.objects.filter(name=self.AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + + def _get_non_authoriser_notification_type(self): + notification_type = NotificationType.objects.get(name=self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + def _get_registration(self): Registration = apps.get_model('osf.Registration') parent_registration = Registration.objects.get(retraction=self) @@ -770,6 +825,9 @@ class RegistrationApproval(SanctionCallbackMixin, EmailApprovableSanction): AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_REGISTRATION_ADMIN NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_REGISTRATION_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_registration_admin' + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_registration_non_admin' + VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' REJECT_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -788,6 +846,22 @@ def find_approval_backlog(): guid=models.F('_id') ).order_by('-initiation_date') + def _get_authoriser_notification_type(self): + notification_type = NotificationType.objects.filter(name=self.AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + + def _get_non_authoriser_notification_type(self): + notification_type = NotificationType.objects.get(name=self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + def _get_registration(self): return self.registrations.first() @@ -935,6 +1009,9 @@ class EmbargoTerminationApproval(EmailApprovableSanction): AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_TERMINATION_ADMIN NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_TERMINATION_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_embargo_termination_admin' + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = 'pending_embargo_termination_non_admin' + VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' REJECT_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -949,6 +1026,22 @@ def is_moderated(self): def _get_registration(self): return self.embargoed_registration + def _get_authoriser_notification_type(self): + notification_type = NotificationType.objects.filter(name=self.AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + + def _get_non_authoriser_notification_type(self): + notification_type = NotificationType.objects.get(name=self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE) + if not notification_type.exists(): + raise NotificationType.DoesNotExist( + f'NotificationType with name {self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE} does not exist.' + ) + return notification_type.first() + def _view_url_context(self, user_id, node): registration = node or self._get_registration() return {