diff --git a/addons/base/views.py b/addons/base/views.py index 16633ddbbfd..38a83edeb77 100644 --- a/addons/base/views.py +++ b/addons/base/views.py @@ -34,7 +34,6 @@ from framework.flask import redirect from framework.sentry import log_exception from framework.transactions.handlers import no_auto_transaction -from website import mails from website import settings from addons.base import signals as file_signals from addons.base.utils import format_last_known_metadata, get_mfr_url @@ -52,7 +51,7 @@ DraftRegistration, Guid, FileVersionUserMetadata, - FileVersion + FileVersion, NotificationType ) from osf.metrics import PreprintView, PreprintDownload from osf.utils import permissions @@ -64,7 +63,7 @@ from website.util import rubeus # import so that associated listener is instantiated and gets emails -from website.notifications.events.files import FileEvent # noqa +from notifications.file_event_notifications import FileEvent # noqa ERROR_MESSAGES = {'FILE_GONE': """ + ''' + f''' + +
+
+

Template Preview for {obj.name}

+

Object Content Type: {obj.object_content_type}

+

Notification Intervals: {', '.join(obj.notification_interval_choices)}

+

Subject:

+

{obj.subject}

+ +

Template:

+
{obj.template}
+
+
+ + ''', content_type='text/html') + + +class NotificationSubscriptionForm(forms.ModelForm): + class Meta: + model = NotificationSubscription + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + notification_type_id = ( + self.data.get('notification_type') or + getattr(self.instance.notification_type, 'id', None) + ) + + if notification_type_id: + try: + nt = NotificationType.objects.get(pk=notification_type_id) + choices = [(x, x) for x in nt.notification_interval_choices] + except NotificationType.DoesNotExist: + choices = [] + else: + choices = [] + + self.fields['message_frequency'] = forms.ChoiceField( + choices=choices, + required=False + ) + +class NotificationSubscriptionAdmin(admin.ModelAdmin): + list_display = ('user', 'notification_type', 'message_frequency', 'subscribed_object', 'preview_button') + form = NotificationSubscriptionForm + + class Media: + js = ['admin/notification_subscription.js'] + + def preview_button(self, obj): + if obj.notification_type: + url = reverse( + 'admin:notificationtype_preview', + args=[obj.notification_type.id] + ) + return format_html( + 'Preview', + url + ) + return format_html( + 'Missing Notification Type!', + ) + + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + 'get-intervals//', + self.admin_site.admin_view(self.get_intervals), + name='get_notification_intervals' + ), + ] + return custom_urls + urls + + def get_intervals(self, request, pk): + try: + nt = NotificationType.objects.get(pk=pk) + return JsonResponse({'intervals': nt.notification_interval_choices}) + except NotificationType.DoesNotExist: + return JsonResponse({'intervals': []}) + + +@admin.register(EmailTask) +class EmailTaskAdmin(admin.ModelAdmin): + list_display = ('task_id', 'user', 'status', 'created_at', 'updated_at') + list_filter = ('status',) + search_fields = ('task_id', 'user__email') + admin.site.register(OSFUser, OSFUserAdmin) admin.site.register(Node, NodeAdmin) admin.site.register(NotableDomain, NotableDomainAdmin) admin.site.register(NodeLicense, LicenseAdmin) +admin.site.register(NotificationType, NotificationTypeAdmin) +admin.site.register(NotificationSubscription, NotificationSubscriptionAdmin) # waffle admins, with Flag admin override admin.site.register(waffle.models.Flag, _ManygroupWaffleFlagAdmin) diff --git a/osf/apps.py b/osf/apps.py index acc2dad7150..f3c5854c913 100644 --- a/osf/apps.py +++ b/osf/apps.py @@ -15,6 +15,7 @@ update_waffle_flags, update_default_providers ) +from osf.management.commands.populate_notification_types import populate_notification_types logger = logging.getLogger(__file__) @@ -68,3 +69,7 @@ def ready(self): update_storage_regions, dispatch_uid='osf.apps.update_storage_regions' ) + post_migrate.connect( + populate_notification_types, + dispatch_uid='osf.apps.populate_notification_types' + ) diff --git a/osf/email/__init__.py b/osf/email/__init__.py new file mode 100644 index 00000000000..b00237b7b27 --- /dev/null +++ b/osf/email/__init__.py @@ -0,0 +1,308 @@ +import os +import re +import json +import logging +import importlib +import sys +from html import unescape +from typing import List, Optional +from mako.template import Template as MakoTemplate + + +import waffle +from django.core.mail import EmailMessage, get_connection + +from mako.lookup import TemplateLookup + +from sendgrid import SendGridAPIClient +from python_http_client.exceptions import ( + BadRequestsError as SGBadRequestsError, + HTTPError as SGHTTPError, + UnauthorizedError as SGUnauthorizedError, + ForbiddenError as SGForbiddenError, +) + +from osf import features +from website import settings + +def _existing_dirs(paths: List[str]) -> List[str]: + out = [] + seen = set() + for p in paths: + if not p: + continue + ap = os.path.abspath(p) + tail = os.path.basename(ap.rstrip(os.sep)) + if tail in ('emails', 'notifications'): + ap = os.path.dirname(ap) + if os.path.isdir(ap) and ap not in seen: + out.append(ap) + seen.add(ap) + return out + +def _default_template_roots() -> List[str]: + roots = [] + cfg = getattr(settings, 'EMAIL_TEMPLATE_DIRS', None) + if cfg: + roots.extend(cfg if isinstance(cfg, (list, tuple)) else [cfg]) + + try: + website_pkg = importlib.import_module('website') + base = os.path.abspath(os.path.dirname(website_pkg.__file__)) + roots.append(os.path.join(base, 'templates')) + except Exception: + pass + + base_path = getattr(settings, 'BASE_PATH', '') + if base_path: + roots.append(os.path.join(base_path, 'website', 'templates')) + + return _existing_dirs(roots) + +LOOKUP_DIRS = _default_template_roots() +MAKO_LOOKUP = TemplateLookup(directories=LOOKUP_DIRS, input_encoding='utf-8') + +def _discover_notify_base_uri() -> Optional[str]: + for root in LOOKUP_DIRS: + for folder in ('emails', 'notifications', ''): + p = os.path.join(root, folder, 'notify_base.mako') + if os.path.exists(p): + rel = os.path.relpath(p, root).replace(os.sep, '/') + return '/' + rel + for root in LOOKUP_DIRS: + for dirpath, _, files in os.walk(root): + if 'notify_base.mako' in files: + rel = os.path.relpath(os.path.join(dirpath, 'notify_base.mako'), root).replace(os.sep, '/') + return '/' + rel + return None + +NOTIFY_BASE_URI = _discover_notify_base_uri() +if not NOTIFY_BASE_URI: + logging.error('Email templates: could not locate notify_base.mako. lookup_dirs=%s', LOOKUP_DIRS) +else: + logging.info('Email templates: notify_base.mako resolved at URI %s (roots=%s)', NOTIFY_BASE_URI, LOOKUP_DIRS) + +def _inline_uri_for_db_template() -> str: + folder = 'emails' + if NOTIFY_BASE_URI: + parts = NOTIFY_BASE_URI.strip('/').split('/') + if len(parts) > 1: + folder = '/'.join(parts[:-1]) + return f'/{folder}/inline_{os.getpid()}_{id(MAKO_LOOKUP)}.mako' + + +INHERIT_RX = re.compile( + r'(<%inherit\s+file=)(["\'])(?:/?(?:emails|notifications)/)?notify_base\.mako\2', + flags=re.I +) + +_VAR_RX = re.compile(r'\$\{\s*([A-Za-z_]\w*)(?:[^\}]*)\}') + +def _extract_vars(src: str) -> set[str]: + return {m.group(1) for m in _VAR_RX.finditer(src or '')} + +def _read_lookup_uri(uri: str) -> str: + """Read template source for a lookup URI using LOOKUP_DIRS.""" + if not uri: + return '' + rel = uri.lstrip('/') + for root in LOOKUP_DIRS: + p = os.path.join(root, rel) + if os.path.exists(p): + try: + with open(p, 'r', encoding='utf-8') as f: + return f.read() + except Exception: + pass + return '' + + +NOTIFY_BASE_DEFAULTS = { + 'logo': settings.OSF_LOGO, # matches default in notify_base.mako + 'logo_url': settings.OSF_LOGO, + 'node_url': '', + 'ns_url': '', + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + 'provider_name': '', +} + +def _render_email_html(notification_type, ctx: dict) -> str: + template_text = notification_type.template + if not template_text: + return '' + + uri = _inline_uri_for_db_template() + text = template_text + if NOTIFY_BASE_URI: + text = INHERIT_RX.sub(rf'\1\2{NOTIFY_BASE_URI}\2', text, count=1) + + # If using notify_base, merge in defaults + if 'notify_base' in text or 'notify_base' in (uri or ''): + for k, v in NOTIFY_BASE_DEFAULTS.items(): + ctx.setdefault(k, v) + + try: + return MakoTemplate( + text=text, + lookup=MAKO_LOOKUP, + uri=uri, + strict_undefined=True, + ).render(**(ctx or {})) + + except Exception: + logging.exception( + f'Mako render failed. type {notification_type.name} provided_keys=%s inline_uri=%s base_uri=%s lookup_dirs=%s', + sorted((ctx or {}).keys()), uri, NOTIFY_BASE_URI, LOOKUP_DIRS, + ) + raise Exception(f'Failed to render email template {notification_type.name}') + +def _strip_html(html: str) -> str: + if not html: + return '' + text = re.sub(r'<(script|style)[^>]*>.*?', '', html, flags=re.S | re.I) + text = re.sub(r'', '\n', text, flags=re.I) + text = re.sub(r'', '\n\n', text, flags=re.I) + text = re.sub(r'<[^>]+>', '', text) + return unescape(re.sub(r'\n{3,}', '\n\n', text)).strip() or '(no content)' + +def _safe_categories(cats): + out = [] + for c in (cats or []): + if isinstance(c, str): + c = c.strip() + if c and len(c) <= 255 and re.fullmatch(r'[\x20-\x7E]+', c): + out.append(c) + return out[:10] + +def send_email_over_smtp(to_email, notification_type, context, email_context): + if waffle.switch_is_active(features.ENABLE_MAILHOG): + host = settings.MAILHOG_HOST + port = settings.MAILHOG_PORT + else: + host = settings.MAIL_SERVER + port = settings.MAIL_PORT + if not host or not port: + raise NotImplementedError('MAIL_SERVER or MAIL_PORT is not set') + + subject = None if not notification_type.subject else notification_type.subject.format(**context) + body_html = _render_email_html(notification_type, context) + + email = EmailMessage( + subject=subject, + body=body_html, + from_email=settings.OSF_SUPPORT_EMAIL, + to=[to_email], + connection=get_connection( + backend='django.core.mail.backends.smtp.EmailBackend', + host=host, + port=port, + username=settings.MAIL_USERNAME, + password=settings.MAIL_PASSWORD, + use_tls=False, + use_ssl=False, + ) + ) + email.content_subtype = 'html' + + if email_context: + attachment_name = email_context.get('attachment_name') + attachment_content = email_context.get('attachment_content') + if attachment_name and attachment_content: + email.attach(attachment_name, attachment_content) + email.send() + +def send_email_with_send_grid(to_addr, notification_type, context, email_context=None): + + email_context = email_context or {} + to_list = [to_addr] if isinstance(to_addr, str) else [a for a in (to_addr or []) if a] + if not to_list: + logging.error('SendGrid: no recipients after normalization') + return False + + from_email = getattr(settings, 'SENDGRID_FROM_EMAIL', None) or getattr(settings, 'FROM_EMAIL', None) + if not from_email: + logging.error('SendGrid: missing SENDGRID_FROM_EMAIL/FROM_EMAIL') + return False + + html = _render_email_html(notification_type, context) or '

(no content)

' + + subject_tpl = getattr(notification_type, 'subject', None) + subject = subject_tpl.format(**context) if subject_tpl else f'Notification: {getattr(notification_type, "name", "OSF")}' + + personalization = {'to': [{'email': addr} for addr in to_list]} + cc_addr = email_context.get('cc_addr') + if cc_addr: + personalization['cc'] = [{'email': a} for a in ([cc_addr] if isinstance(cc_addr, str) else cc_addr)] + bcc_addr = email_context.get('bcc_addr') + if bcc_addr: + personalization['bcc'] = [{'email': a} for a in ([bcc_addr] if isinstance(bcc_addr, str) else bcc_addr)] + + payload = { + 'from': {'email': from_email}, + 'subject': subject, + 'personalizations': [personalization], + 'content': [ + {'type': 'text/html', 'value': html}, + ], + } + + reply_to = email_context.get('reply_to') + if reply_to: + payload['reply_to'] = {'email': reply_to} + + cats = _safe_categories(email_context.get('email_categories')) + if cats: + payload['categories'] = cats + + try: + sg = SendGridAPIClient(settings.SENDGRID_API_KEY) + resp = sg.client.mail.send.post(request_body=payload) + if resp.status_code not in (200, 201, 202): + logging.error( + 'SendGrid non-2xx: code=%s body=%s payload=%s', + resp.status_code, + getattr(resp, 'body', b'').decode('utf-8', 'ignore'), + payload + ) + resp.raise_for_status() + logging.info('Notification email sent to %s for %s.', to_list, getattr(notification_type, 'name', str(notification_type))) + return True + + except SGBadRequestsError as exc: + body = None + try: + body = exc.body.decode('utf-8', 'ignore') if isinstance(exc.body, (bytes, bytearray)) else str(exc.body) + parsed = json.loads(body) + except Exception: + parsed = {'raw_body': body} + logging.error('SendGrid 400 Bad Request: %s | payload=%s', parsed, payload) + if isinstance(parsed, dict) and 'errors' in parsed: + for err in parsed['errors']: + logging.error('SendGrid error: message=%r field=%r help=%r', + err.get('message'), err.get('field'), err.get('help')) + raise + + except (SGUnauthorizedError, SGForbiddenError) as exc: + body = getattr(exc, 'body', b'') + try: + body = body.decode('utf-8', 'ignore') if isinstance(body, (bytes, bytearray)) else str(body) + except Exception: + pass + logging.error('SendGrid auth error (%s): %s', exc.__class__.__name__, body) + raise + + except SGHTTPError as exc: + body = getattr(exc, 'body', b'') + try: + body = body.decode('utf-8', 'ignore') if isinstance(body, (bytes, bytearray)) else str(body) + except Exception: + pass + logging.error('SendGrid HTTPError: %s | payload=%s', body, payload) + raise + except Exception as exc: + if 'pytest' in sys.modules: + logging.error(f'You sent an email of {notification_type.name} while in the local test environment, try' + f' using `capture_notifications` or `assert_notifications` instead') + else: + logging.error('SendGrid hit a blocked socket error: %r | payload=%s', exc, payload) + raise diff --git a/osf/management/commands/add_colon_delim_to_s3_buckets.py b/osf/management/commands/add_colon_delim_to_s3_buckets.py deleted file mode 100644 index 0a283f78f0f..00000000000 --- a/osf/management/commands/add_colon_delim_to_s3_buckets.py +++ /dev/null @@ -1,76 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand -from django.apps import apps -from django.db.models import F, Value -from django.db.models.functions import Concat, Replace - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """ - Adds Colon (':') delineators to s3 buckets to separate them from them from their subfolder, so `` - becomes `:/` , the root path. Folder names will also be updated to maintain consistency. - - """ - - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--reverse', - action='store_true', - dest='reverse', - help='Unsets date_retraction' - ) - - def handle(self, *args, **options): - reverse = options.get('reverse', False) - if reverse: - reverse_update_folder_names() - else: - update_folder_names() - - -def update_folder_names(): - NodeSettings = apps.get_model('addons_s3', 'NodeSettings') - - # Update folder_id for all records - NodeSettings.objects.exclude( - folder_name__contains=':/' - ).update( - folder_id=Concat(F('folder_id'), Value(':/')) - ) - - # Update folder_name for records containing '(' - NodeSettings.objects.filter( - folder_name__contains=' (' - ).exclude( - folder_name__contains=':/' - ).update( - folder_name=Replace(F('folder_name'), Value(' ('), Value(':/ (')) - ) - NodeSettings.objects.exclude( - folder_name__contains=':/' - ).exclude( - folder_name__contains=' (' - ).update( - folder_name=Concat(F('folder_name'), Value(':/')) - ) - logger.info('Update Folder Names/IDs complete') - - -def reverse_update_folder_names(): - NodeSettings = apps.get_model('addons_s3', 'NodeSettings') - - # Reverse update folder_id for all records - NodeSettings.objects.update(folder_id=Replace(F('folder_id'), Value(':/'), Value(''))) - - # Reverse update folder_name for records containing ':/ (' - NodeSettings.objects.filter(folder_name__contains=':/ (').update( - folder_name=Replace(F('folder_name'), Value(':/ ('), Value(' (')) - ) - NodeSettings.objects.filter(folder_name__contains=':/').update( - folder_name=Replace(F('folder_name'), Value(':/'), Value('')) - ) - logger.info('Reverse Update Folder Names/IDs complete') diff --git a/osf/management/commands/add_egap_registration_schema.py b/osf/management/commands/add_egap_registration_schema.py deleted file mode 100644 index ea5df1e7f4a..00000000000 --- a/osf/management/commands/add_egap_registration_schema.py +++ /dev/null @@ -1,29 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand -from osf.models import RegistrationSchema -from website.project.metadata.schemas import ensure_schema_structure, from_json - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """Add egap-registration schema to the db. - For now, doing this outside of a migration so it can be individually added to - a staging environment for preview. - """ - - def handle(self, *args, **options): - egap_registration_schema = ensure_schema_structure(from_json('egap-registration-3.json')) - schema_obj, created = RegistrationSchema.objects.update_or_create( - name=egap_registration_schema['name'], - schema_version=egap_registration_schema.get('version', 1), - defaults={ - 'schema': egap_registration_schema, - } - ) - - if created: - logger.info('Added schema {} to the database'.format(egap_registration_schema['name'])) - else: - logger.info('updated existing schema {}'.format(egap_registration_schema['name'])) diff --git a/osf/management/commands/add_institution_perm_groups.py b/osf/management/commands/add_institution_perm_groups.py deleted file mode 100644 index d7becaf2d8b..00000000000 --- a/osf/management/commands/add_institution_perm_groups.py +++ /dev/null @@ -1,19 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand -from osf.models import Institution - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """A new permissions group was created for Institutions, which will be created upon each new Institution, - but the old institutions will not have this group. This management command creates those groups for the - existing institutions. - """ - - def handle(self, *args, **options): - institutions = Institution.objects.all() - for institution in institutions: - institution.update_group_permissions() - logger.info(f'Added perms to {institution.name}.') diff --git a/osf/management/commands/add_notification_subscription.py b/osf/management/commands/add_notification_subscription.py deleted file mode 100644 index 7d9a404f37a..00000000000 --- a/osf/management/commands/add_notification_subscription.py +++ /dev/null @@ -1,77 +0,0 @@ -# This is a management command, rather than a migration script, for two primary reasons: -# 1. It makes no changes to database structure (e.g. AlterField), only database content. -# 2. It takes a long time to run and the site doesn't need to be down that long. - -import logging - -import django -django.setup() - -from django.core.management.base import BaseCommand -from django.db import transaction - -from website.notifications.utils import to_subscription_key - -from scripts import utils as script_utils - -logger = logging.getLogger(__name__) - - -def add_reviews_notification_setting(notification_type, state=None): - if state: - OSFUser = state.get_model('osf', 'OSFUser') - NotificationSubscription = state.get_model('osf', 'NotificationSubscription') - else: - from osf.models import OSFUser, NotificationSubscription - - 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() - - logger.info(f'About to add a global_reviews setting for {total_active_users} users.') - - total_created = 0 - for user in active_users.iterator(): - user_subscription_id = to_subscription_key(user._id, notification_type) - - subscription = NotificationSubscription.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.save() # Need to save in order to access m2m fields - subscription.add_user_to_subscription(user, 'email_transactional') - else: - logger.info(f'User {user._id} already has a {notification_type} subscription') - total_created += 1 - - logger.info(f'Added subscriptions for {total_created}/{total_active_users} users') - - -class Command(BaseCommand): - """ - Add subscription to all active users for given notification type. - """ - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--dry', - action='store_true', - dest='dry_run', - help='Run migration and roll back changes to db', - ) - - parser.add_argument( - '--notification', - type=str, - required=True, - help='Notification type to subscribe users to', - ) - - def handle(self, *args, **options): - dry_run = options.get('dry_run', False) - state = options.get('state', None) - if not dry_run: - script_utils.add_file_logger(logger, __file__) - with transaction.atomic(): - add_reviews_notification_setting(notification_type=options['notification'], state=state) - if dry_run: - raise RuntimeError('Dry run, transaction rolled back.') diff --git a/osf/management/commands/addon_deleted_date.py b/osf/management/commands/addon_deleted_date.py deleted file mode 100644 index df2f78b26e0..00000000000 --- a/osf/management/commands/addon_deleted_date.py +++ /dev/null @@ -1,96 +0,0 @@ -import datetime -import logging - -from django.core.management.base import BaseCommand -from django.db import connection, transaction -from framework.celery_tasks import app as celery_app - -logger = logging.getLogger(__name__) - -TABLES_TO_POPULATE_WITH_MODIFIED = [ - 'addons_zotero_usersettings', - 'addons_dropbox_usersettings', - 'addons_dropbox_nodesettings', - 'addons_figshare_nodesettings', - 'addons_figshare_usersettings', - 'addons_forward_nodesettings', - 'addons_github_nodesettings', - 'addons_github_usersettings', - 'addons_gitlab_nodesettings', - 'addons_gitlab_usersettings', - 'addons_googledrive_nodesettings', - 'addons_googledrive_usersettings', - 'addons_mendeley_nodesettings', - 'addons_mendeley_usersettings', - 'addons_onedrive_nodesettings', - 'addons_onedrive_usersettings', - 'addons_osfstorage_nodesettings', - 'addons_osfstorage_usersettings', - 'addons_bitbucket_nodesettings', - 'addons_bitbucket_usersettings', - 'addons_owncloud_nodesettings', - 'addons_box_nodesettings', - 'addons_owncloud_usersettings', - 'addons_box_usersettings', - 'addons_dataverse_nodesettings', - 'addons_dataverse_usersettings', - 'addons_s3_nodesettings', - 'addons_s3_usersettings', - 'addons_twofactor_usersettings', - 'addons_wiki_nodesettings', - 'addons_zotero_nodesettings' -] - -UPDATE_DELETED_WITH_MODIFIED = """UPDATE {} SET deleted=modified - WHERE id IN (SELECT id FROM {} WHERE is_deleted AND deleted IS NULL LIMIT {}) RETURNING id;""" - -@celery_app.task(name='management.commands.addon_deleted_date') -def populate_deleted(dry_run=False, page_size=1000): - with transaction.atomic(): - for table in TABLES_TO_POPULATE_WITH_MODIFIED: - run_statements(UPDATE_DELETED_WITH_MODIFIED, page_size, table) - if dry_run: - raise RuntimeError('Dry Run -- Transaction rolled back') - -def run_statements(statement, page_size, table): - logger.info(f'Populating deleted column in table {table}') - with connection.cursor() as cursor: - cursor.execute(statement.format(table, table, page_size)) - rows = cursor.fetchall() - if rows: - logger.info(f'Table {table} still has rows to populate') - -class Command(BaseCommand): - help = '''Populates new deleted field for various models. Ensure you have run migrations - before running this script.''' - - def add_arguments(self, parser): - parser.add_argument( - '--dry_run', - type=bool, - default=False, - help='Run queries but do not write files', - ) - parser.add_argument( - '--page_size', - type=int, - default=1000, - help='How many rows to process at a time', - ) - - def handle(self, *args, **options): - script_start_time = datetime.datetime.now() - logger.info(f'Script started time: {script_start_time}') - logger.debug(options) - - dry_run = options['dry_run'] - page_size = options['page_size'] - - if dry_run: - logger.info('DRY RUN') - - populate_deleted(dry_run, page_size) - - script_finish_time = datetime.datetime.now() - logger.info(f'Script finished time: {script_finish_time}') - logger.info(f'Run time {script_finish_time - script_start_time}') diff --git a/osf/management/commands/backfill_date_retracted.py b/osf/management/commands/backfill_date_retracted.py deleted file mode 100644 index 698a67c82ae..00000000000 --- a/osf/management/commands/backfill_date_retracted.py +++ /dev/null @@ -1,89 +0,0 @@ -# This is a management command, rather than a migration script, for two primary reasons: -# 1. It makes no changes to database structure (e.g. AlterField), only database content. -# 2. It may need to be ran more than once, as it skips failed registrations. - -from datetime import timedelta -import logging - -import django -django.setup() - -from django.core.management.base import BaseCommand -from django.db import transaction - -from osf.models import Registration, Retraction, Sanction -from scripts import utils as script_utils - -logger = logging.getLogger(__name__) - -def set_date_retracted(*args): - registrations = ( - Registration.objects.filter(retraction__state=Sanction.APPROVED, retraction__date_retracted=None) - .select_related('retraction') - .prefetch_related('registered_from__logs') - .prefetch_related('registered_from__guids') - ) - total = registrations.count() - logger.info(f'Migrating {total} retractions.') - - for registration in registrations: - if not registration.registered_from: - logger.warning(f'Skipping failed registration {registration._id}') - continue - retraction_logs = registration.registered_from.logs.filter(action='retraction_approved', params__retraction_id=registration.retraction._id) - if retraction_logs.count() != 1 and retraction_logs.first().date - retraction_logs.last().date > timedelta(seconds=5): - msg = ( - 'There should be a retraction_approved log for retraction {} on node {}. No retraction_approved log found.' - if retraction_logs.count() == 0 - else 'There should only be one retraction_approved log for retraction {} on node {}. Multiple logs found.' - ) - raise Exception(msg.format(registration.retraction._id, registration.registered_from._id)) - date_retracted = retraction_logs[0].date - logger.info( - 'Setting date_retracted for retraction {} to be {}, from retraction_approved node log {}.'.format( - registration.retraction._id, date_retracted, retraction_logs[0]._id - ) - ) - registration.retraction.date_retracted = date_retracted - registration.retraction.save() - -def unset_date_retracted(*args): - retractions = Retraction.objects.filter(state=Sanction.APPROVED).exclude(date_retracted=None) - logger.info(f'Migrating {retractions.count()} retractions.') - - for retraction in retractions: - retraction.date_retracted = None - retraction.save() - - -class Command(BaseCommand): - """ - Backfill Retraction.date_retracted with `RETRACTION_APPROVED` log date. - """ - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--dry', - action='store_true', - dest='dry_run', - help='Run migration and roll back changes to db', - ) - parser.add_argument( - '--reverse', - action='store_true', - dest='reverse', - help='Unsets date_retraction' - ) - - def handle(self, *args, **options): - reverse = options.get('reverse', False) - dry_run = options.get('dry_run', False) - if not dry_run: - script_utils.add_file_logger(logger, __file__) - with transaction.atomic(): - if reverse: - unset_date_retracted() - else: - set_date_retracted() - if dry_run: - raise RuntimeError('Dry run, transaction rolled back.') diff --git a/osf/management/commands/check_crossref_dois.py b/osf/management/commands/check_crossref_dois.py deleted file mode 100644 index bee66856747..00000000000 --- a/osf/management/commands/check_crossref_dois.py +++ /dev/null @@ -1,160 +0,0 @@ -from datetime import timedelta -import logging -import requests - -import django -from django.core.management.base import BaseCommand -from django.utils import timezone -django.setup() - -from framework import sentry -from framework.celery_tasks import app as celery_app -from osf.models import Guid, Preprint -from website import mails, settings - - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - -time_since_published = timedelta(days=settings.DAYS_CROSSREF_DOIS_MUST_BE_STUCK_BEFORE_EMAIL) - -CHECK_DOIS_BATCH_SIZE = 20 - - -def pop_slice(lis, n): - tem = lis[:n] - del lis[:n] - return tem - -def mint_doi_for_preprints_locally(confirm_local=False): - """This method creates identifiers for preprints which have pending DOI in local environment only. - """ - if not settings.DEV_MODE or not settings.DEBUG_MODE: - logger.error('This command should only run in the local development environment.') - return - if not confirm_local: - logger.error('You must explicitly set `confirm_local` to run this command.') - return - - preprints_with_pending_doi = Preprint.objects.filter(preprint_doi_created__isnull=True, is_published=True) - total_created = 0 - for preprint in preprints_with_pending_doi: - client = preprint.get_doi_client() - doi = client.build_doi(preprint=preprint) if client else None - if doi: - logger.info(f'Minting DOI [{doi}] for Preprint [{preprint._id}].') - preprint.set_identifier_values(doi, save=True) - total_created += 1 - logger.info(f'[{total_created}] DOIs minted.') - -def check_crossref_dois(dry_run=True): - """ - This script is to check for any DOI confirmation messages we may have missed during downtime and alert admins to any - DOIs that have been pending for X number of days. It creates url to check with crossref if all our pending crossref - DOIs are minted, then sets all identifiers which are confirmed minted. - - :param dry_run: - :return: - """ - - preprints_with_pending_dois = Preprint.objects.filter( - preprint_doi_created__isnull=True, - is_published=True - ).exclude(date_published__gt=timezone.now() - time_since_published) - - if not preprints_with_pending_dois.exists(): - return - - preprints = list(preprints_with_pending_dois) - - while preprints: - preprint_batch = pop_slice(preprints, CHECK_DOIS_BATCH_SIZE) - - pending_dois = [] - for preprint in preprint_batch: - doi_prefix = preprint.provider.doi_prefix - if not doi_prefix: - sentry.log_message(f'Preprint [_id={preprint._id}] has been skipped for CrossRef DOI Check ' - f'since the provider [_id={preprint.provider._id}] has invalid DOI Prefix ' - f'[doi_prefix={doi_prefix}]') - continue - pending_dois.append(f'doi:{settings.DOI_FORMAT.format(prefix=doi_prefix, guid=preprint._id)}') - - if not pending_dois: - continue - - url = '{}works?filter={}'.format(settings.CROSSREF_JSON_API_URL, ','.join(pending_dois)) - - try: - resp = requests.get(url) - resp.raise_for_status() - except requests.exceptions.HTTPError as exc: - sentry.log_message(f'Could not contact crossref to check for DOIs, response returned with exception {exc}') - continue - - preprints_response = resp.json()['message']['items'] - - for preprint in preprints_response: - preprint__id = preprint['DOI'].split('/')[-1] - base_guid, version = Guid.split_guid(preprint__id) - if not base_guid or not version: - sentry.log_message(f'[Skipped] Preprint [_id={preprint__id}] returned by CrossRef API has invalid _id') - continue - pending_preprint = preprints_with_pending_dois.filter( - versioned_guids__guid___id=base_guid, - versioned_guids__version=version, - ).first() - if not pending_preprint: - sentry.log_message(f'[Skipped] Preprint [_id={preprint__id}] returned by CrossRef API is not found.') - continue - if not dry_run: - logger.debug(f'Set identifier for {pending_preprint._id}') - pending_preprint.set_identifier_values(preprint['DOI'], save=True) - else: - logger.info(f'DRY RUN: Set identifier for {pending_preprint._id}') - - -def report_stuck_dois(dry_run=True): - - preprints_with_pending_dois = Preprint.objects.filter(preprint_doi_created__isnull=True, - is_published=True, - date_published__lt=timezone.now() - time_since_published) - - if preprints_with_pending_dois: - guids = ', '.join(preprints_with_pending_dois.values_list('guids___id', flat=True)) - if not dry_run: - mails.send_mail( - to_addr=settings.OSF_SUPPORT_EMAIL, - mail=mails.CROSSREF_DOIS_PENDING, - pending_doi_count=preprints_with_pending_dois.count(), - time_since_published=time_since_published.days, - guids=guids, - ) - else: - logger.info('DRY RUN') - - logger.info(f'There were {preprints_with_pending_dois.count()} stuck registrations for CrossRef, email sent to help desk') - - -@celery_app.task(name='management.commands.check_crossref_dois') -def main(dry_run=False): - check_crossref_dois(dry_run=dry_run) - report_stuck_dois(dry_run=dry_run) - - -class Command(BaseCommand): - help = '''Checks if we've missed any Crossref DOI confirmation emails. ''' - - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--dry', - action='store_true', - dest='dry_run', - help='Dry run', - ) - - # Management command handler - def handle(self, *args, **options): - dry_run = options.get('dry_run', True) - main(dry_run=dry_run) diff --git a/osf/management/commands/create_fake_preprint_actions.py b/osf/management/commands/create_fake_preprint_actions.py deleted file mode 100644 index 85b28ae9f20..00000000000 --- a/osf/management/commands/create_fake_preprint_actions.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python3 - -import random -import logging -from faker import Faker - -from django.core.management.base import BaseCommand - -from osf.models import ReviewAction, Preprint, OSFUser -from osf.utils.workflows import DefaultStates, DefaultTriggers - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """Add fake Actions to every preprint that doesn't already have one""" - - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - 'user', - type=str, - nargs='?', - default=None, - help='Guid for user to list as creator for all fake actions (default to arbitrary user)' - ) - parser.add_argument( - '--num-actions', - action='store', - type=int, - default=10, - help='Number of actions to create for each preprint which does not have one' - ) - - def handle(self, *args, **options): - user_guid = options.get('user') - num_actions = options.get('--num-actions') - - if user_guid is None: - user = OSFUser.objects.first() - else: - user = OSFUser.objects.get(guids___id=user_guid) - - fake = Faker() - triggers = [a.value for a in DefaultTriggers] - states = [s.value for s in DefaultStates] - for preprint in Preprint.objects.filter(actions__isnull=True): - for i in range(num_actions): - action = ReviewAction( - target=preprint, - creator=user, - trigger=random.choice(triggers), - from_state=random.choice(states), - to_state=random.choice(states), - comment=fake.text(), - ) - action.save() diff --git a/osf/management/commands/deactivate_requested_accounts.py b/osf/management/commands/deactivate_requested_accounts.py index 512fb34eeef..8a4eeaf9ad1 100644 --- a/osf/management/commands/deactivate_requested_accounts.py +++ b/osf/management/commands/deactivate_requested_accounts.py @@ -1,14 +1,13 @@ import logging -from website import mails from django.utils import timezone from framework.celery_tasks import app as celery_app from website.app import setup_django setup_django() -from osf.models import OSFUser -from website.settings import OSF_SUPPORT_EMAIL, OSF_CONTACT_EMAIL +from osf.models import OSFUser, NotificationType from django.core.management.base import BaseCommand +from website.settings import OSF_SUPPORT_EMAIL logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -21,22 +20,28 @@ def deactivate_requested_accounts(dry_run=True): if user.has_resources: logger.info(f'OSF support is being emailed about deactivating the account of user {user._id}.') if not dry_run: - mails.send_mail( - to_addr=OSF_SUPPORT_EMAIL, - mail=mails.REQUEST_DEACTIVATION, + NotificationType.Type.DESK_REQUEST_DEACTIVATION.instance.emit( + destination_address=OSF_SUPPORT_EMAIL, user=user, - can_change_preferences=False, + event_context={ + 'user__id': user._id, + 'user_absolute_url': user.absolute_url, + 'user_username': user.username, + 'can_change_preferences': False, + } ) else: logger.info(f'Disabling user {user._id}.') if not dry_run: user.deactivate_account() - mails.send_mail( - to_addr=user.username, - mail=mails.REQUEST_DEACTIVATION_COMPLETE, + user.is_registered = False + NotificationType.Type.USER_REQUEST_DEACTIVATION_COMPLETE.instance.emit( user=user, - contact_email=OSF_CONTACT_EMAIL, - can_change_preferences=False, + event_context={ + 'user_fullname': user.fullname, + 'contact_email': OSF_SUPPORT_EMAIL, + 'can_change_preferences': False, + } ) user.contacted_deactivation = True diff --git a/osf/management/commands/email_all_users.py b/osf/management/commands/email_all_users.py deleted file mode 100644 index f5cbd677fb7..00000000000 --- a/osf/management/commands/email_all_users.py +++ /dev/null @@ -1,114 +0,0 @@ -# This is a management command, rather than a migration script, for two primary reasons: -# 1. It makes no changes to database structure (e.g. AlterField), only database content. -# 2. It takes a long time to run and the site doesn't need to be down that long. - -import logging - - -import django -django.setup() - -from django.core.management.base import BaseCommand -from framework import sentry - -from website import mails - -from osf.models import OSFUser - -logger = logging.getLogger(__name__) - -OFFSET = 500000 - -def email_all_users(email_template, dry_run=False, ids=None, start_id=0, offset=OFFSET): - - if ids: - active_users = OSFUser.objects.filter(id__in=ids) - else: - lower_bound = start_id - upper_bound = start_id + offset - base_query = OSFUser.objects.filter(date_confirmed__isnull=False, deleted=None).exclude(date_disabled__isnull=False).exclude(is_active=False) - active_users = base_query.filter(id__gt=lower_bound, id__lte=upper_bound).order_by('id') - - if dry_run: - active_users = active_users.exclude(is_superuser=False) - - total_active_users = active_users.count() - - logger.info(f'About to send an email to {total_active_users} users.') - - template = getattr(mails, email_template, None) - if not template: - raise RuntimeError('Invalid email template specified!') - - total_sent = 0 - for user in active_users.iterator(): - logger.info(f'Sending email to {user.id}') - try: - mails.send_mail( - to_addr=user.email, - mail=template, - given_name=user.given_name or user.fullname, - ) - except Exception as e: - logger.error(f'Exception encountered sending email to {user.id}') - sentry.log_exception(e) - continue - else: - total_sent += 1 - - logger.info(f'Emails sent to {total_sent}/{total_active_users} users') - - -class Command(BaseCommand): - """ - Add subscription to all active users for given notification type. - """ - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--dry', - action='store_true', - dest='dry_run', - help='Test - Only send to superusers' - ) - - parser.add_argument( - '--t', - type=str, - dest='template', - required=True, - help='Specify which template to use' - ) - - parser.add_argument( - '--start-id', - type=int, - dest='start_id', - default=0, - help='Specify id to start from.' - ) - - parser.add_argument( - '--ids', - dest='ids', - nargs='+', - help='Specific IDs to email, otherwise will email all users' - ) - - parser.add_argument( - '--o', - type=int, - dest='offset', - default=OFFSET, - help=f'How many users to email in this run, default is {OFFSET}' - ) - - def handle(self, *args, **options): - dry_run = options.get('dry_run', False) - template = options.get('template') - start_id = options.get('start_id') - ids = options.get('ids') - offset = options.get('offset', OFFSET) - email_all_users(template, dry_run, start_id=start_id, ids=ids, offset=offset) - if dry_run: - raise RuntimeError('Dry run, only superusers emailed') diff --git a/osf/management/commands/fake_metrics_reports.py b/osf/management/commands/fake_metrics_reports.py deleted file mode 100644 index 765d6e475c1..00000000000 --- a/osf/management/commands/fake_metrics_reports.py +++ /dev/null @@ -1,62 +0,0 @@ -from datetime import date, timedelta -from random import randint - -from django.conf import settings -from django.core.management.base import BaseCommand - -from osf.metrics import ( - UserSummaryReport, - PreprintSummaryReport, -) -from osf.models import PreprintProvider - - -def fake_user_counts(days_back): - yesterday = date.today() - timedelta(days=1) - first_report = UserSummaryReport( - report_date=(yesterday - timedelta(days=days_back)), - active=randint(0, 23), - deactivated=randint(0, 2), - merged=randint(0, 4), - new_users_daily=randint(0, 7), - new_users_with_institution_daily=randint(0, 5), - unconfirmed=randint(0, 3), - ) - first_report.save() - - last_report = first_report - while last_report.report_date < yesterday: - new_user_count = randint(0, 500) - new_report = UserSummaryReport( - report_date=(last_report.report_date + timedelta(days=1)), - active=(last_report.active + randint(0, new_user_count)), - deactivated=(last_report.deactivated + randint(0, new_user_count)), - merged=(last_report.merged + randint(0, new_user_count)), - new_users_daily=new_user_count, - new_users_with_institution_daily=randint(0, new_user_count), - unconfirmed=(last_report.unconfirmed + randint(0, new_user_count)), - ) - new_report.save() - last_report = new_report - - -def fake_preprint_counts(days_back): - yesterday = date.today() - timedelta(days=1) - provider_keys = PreprintProvider.objects.all().values_list('_id', flat=True) - for day_delta in range(days_back): - for provider_key in provider_keys: - preprint_count = randint(100, 5000) * (days_back - day_delta) - PreprintSummaryReport( - report_date=yesterday - timedelta(days=day_delta), - provider_key=provider_key, - preprint_count=preprint_count, - ).save() - - -class Command(BaseCommand): - def handle(self, *args, **kwargs): - if not settings.DEBUG: - raise NotImplementedError('fake_reports requires DEBUG mode') - fake_user_counts(1000) - fake_preprint_counts(1000) - # TODO: more reports diff --git a/osf/management/commands/find_spammy_files.py b/osf/management/commands/find_spammy_files.py deleted file mode 100644 index 33d25366ea1..00000000000 --- a/osf/management/commands/find_spammy_files.py +++ /dev/null @@ -1,113 +0,0 @@ -import io -import csv -from datetime import timedelta -import logging - -from django.core.management.base import BaseCommand -from django.utils import timezone - -from addons.osfstorage.models import OsfStorageFile -from framework.celery_tasks import app -from website import mails - -logger = logging.getLogger(__name__) - - -@app.task(name='osf.management.commands.find_spammy_files') -def find_spammy_files(sniff_r=None, n=None, t=None, to_addrs=None): - if not sniff_r: - raise RuntimeError('Require arg sniff_r not found') - if isinstance(sniff_r, str): - sniff_r = [sniff_r] - if isinstance(to_addrs, str): - to_addrs = [to_addrs] - for sniff in sniff_r: - filename = f'spam_files_{sniff}.csv' - filepath = f'/tmp/{filename}' - fieldnames = ['f.name', 'f._id', 'f.created', 'n._id', 'u._id', 'u.username', 'u.fullname'] - output = io.StringIO() - writer = csv.DictWriter(output, fieldnames) - writer.writeheader() - qs = OsfStorageFile.objects.filter(name__iregex=sniff) - if t: - qs = qs.filter(created__gte=timezone.now() - timedelta(days=t)) - if n: - qs = qs[:n] - ct = 0 - for f in qs: - node = f.target - user = getattr(f.versions.first(), 'creator', node.creator) - if f.target.deleted or user.is_disabled: - continue - ct += 1 - writer.writerow({ - 'f.name': f.name, - 'f._id': f._id, - 'f.created': f.created, - 'n._id': node._id, - 'u._id': user._id, - 'u.username': user.username, - 'u.fullname': user.fullname - }) - if ct: - if to_addrs: - for addr in to_addrs: - mails.send_mail( - mail=mails.SPAM_FILES_DETECTED, - to_addr=addr, - ct=ct, - sniff_r=sniff, - attachment_name=filename, - attachment_content=output.getvalue(), - can_change_preferences=False, - ) - else: - with open(filepath, 'w') as writeFile: - writeFile.write(output.getvalue()) - -class Command(BaseCommand): - help = '''Script to match filenames to common spammy names.''' - - def add_arguments(self, parser): - parser.add_argument( - '--sniff_r', - type=str, - nargs='+', - required=True, - help='Regex to match against file.name', - ) - parser.add_argument( - '--n', - type=int, - default=None, - help='Max number of files to return', - ) - parser.add_argument( - '--t', - type=int, - default=None, - help='Number of days to search through', - ) - parser.add_argument( - '--to_addrs', - type=str, - nargs='*', - default=None, - help='Email address(es) to send the resulting file to. If absent, write to csv in /tmp/', - ) - - def handle(self, *args, **options): - script_start_time = timezone.now() - logger.info(f'Script started time: {script_start_time}') - logger.debug(options) - - sniff_r = options.get('sniff_r') - n = options.get('n', None) - t = options.get('t', None) - to_addrs = options.get('to_addrs', None) - - find_spammy_files(sniff_r=sniff_r, n=n, t=t, to_addrs=to_addrs) - - script_finish_time = timezone.now() - logger.info(f'Script finished time: {script_finish_time}') - logger.info(f'Run time {script_finish_time - script_start_time}') diff --git a/osf/management/commands/force_archive.py b/osf/management/commands/force_archive.py index e2667325c15..1f5612a2f91 100644 --- a/osf/management/commands/force_archive.py +++ b/osf/management/commands/force_archive.py @@ -36,7 +36,7 @@ from addons.osfstorage.models import OsfStorageFile, OsfStorageFolder, OsfStorageFileNode from framework import sentry from framework.exceptions import HTTPError -from osf.models import AbstractNode, Node, NodeLog, Registration, BaseFileNode +from osf.models import Node, NodeLog, Registration, BaseFileNode from osf.models.files import TrashedFileNode from osf.exceptions import RegistrationStuckRecoverableException, RegistrationStuckBrokenException from api.base.utils import waterbutler_api_url_for @@ -285,32 +285,33 @@ def get_file_obj_from_log(log, reg): try: return BaseFileNode.objects.get(_id=log.params['urls']['view'].split('/')[4]) except KeyError: - path = log.params.get('path', '').split('/') - if log.action in ['addon_file_moved', 'addon_file_renamed']: + if log.action == 'osf_storage_folder_created': + return OsfStorageFolder.objects.get( + target_object_id=reg.registered_from.id, + name=log.params['path'].split('/')[-2] + ) + elif log.action == 'osf_storage_file_removed': + path = log.params['path'].split('/') + return TrashedFileNode.objects.get( + target_object_id=reg.registered_from.id, + name=path[-1] or path[-2] # file name or folder name + ) + elif log.action in ['addon_file_moved', 'addon_file_renamed']: try: return BaseFileNode.objects.get(_id=log.params['source']['path'].rstrip('/').split('/')[-1]) except (KeyError, BaseFileNode.DoesNotExist): return BaseFileNode.objects.get(_id=log.params['destination']['path'].rstrip('/').split('/')[-1]) - elif log.action == 'osf_storage_file_removed': - candidates = BaseFileNode.objects.filter( - target_object_id=reg.registered_from.id, - target_content_type_id=ContentType.objects.get_for_model(AbstractNode).id, - name=path[-1] or path[-2], - deleted_on__lte=log.date - ).order_by('-deleted_on') else: # Generic fallback - candidates = BaseFileNode.objects.filter( - target_object_id=reg.registered_from.id, - target_content_type_id=ContentType.objects.get_for_model(AbstractNode).id, - name=path[-1] or path[-2], - created__lte=log.date - ).order_by('-created') - - if candidates.exists(): - return candidates.first() + path = log.params.get('path', '').split('/') + if len(path) >= 2: + name = path[-1] or path[-2] # file name or folder name + return BaseFileNode.objects.get( + target_object_id=reg.registered_from.id, + name=name + ) - raise BaseFileNode.DoesNotExist(f"No file found for name '{path[-1] or path[-2]}' before {log.date}") + raise ValueError(f'Cannot determine file obj for log {log._id} [Registration id {reg._id}]: {log.action}') def handle_file_operation(file_tree, reg, file_obj, log, obj_cache): diff --git a/osf/management/commands/make_dummy_pageviews_for_metrics.py b/osf/management/commands/make_dummy_pageviews_for_metrics.py deleted file mode 100644 index 09de34bf7a8..00000000000 --- a/osf/management/commands/make_dummy_pageviews_for_metrics.py +++ /dev/null @@ -1,118 +0,0 @@ -"""osf/management/commands/poke_metrics_timespan_queries.py -""" -import logging -import random -import datetime - -from django.core.management.base import BaseCommand -from osf.metrics import CountedAuthUsage - - -logger = logging.getLogger(__name__) - -TIME_FILTERS = ( - {'gte': 'now/d-150d'}, - {'gte': '2021-11-28T23:00:00.000Z', 'lte': '2023-01-16T00:00:00.000Z'}, -) - -PLATFORM_IRI = 'http://localhost:9201/' - -ITEM_GUID = 'foo' - - -class Command(BaseCommand): - - def add_arguments(self, parser): - parser.add_argument( - '--count', - type=int, - default=100, - help='number of fake pageviews to generate', - ) - parser.add_argument( - '--seconds_back', - type=int, - default=60 * 60 * 24 * 14, # up to two weeks back - help='max age in seconds of random event', - ) - - def handle(self, *args, **options): - self._generate_random_countedusage(options.get('count'), options.get('seconds_back')) - - results = [ - self._run_date_query(time_filter) - for time_filter in TIME_FILTERS - ] - - self._print_line( - (str(f) for f in TIME_FILTERS), - label='timefilter:', - ) - - date_keys = { - k - for r in results - for k in r - } - for date_key in sorted(date_keys): - self._print_line( - (r.get(date_key, 0) for r in results), - label=str(date_key), - ) - - def _print_line(self, lineitems, label=''): - print('\t'.join((label, *map(str, lineitems)))) - - def _generate_random_countedusage(self, n, max_age): - now = datetime.datetime.now(tz=datetime.UTC) - for _ in range(n): - seconds_back = random.randint(0, max_age) - timestamp_time = now - datetime.timedelta(seconds=seconds_back) - CountedAuthUsage.record( - platform_iri=PLATFORM_IRI, - timestamp=timestamp_time, - item_guid=ITEM_GUID, - session_id='freshen by key', - user_is_authenticated=bool(random.randint(0, 1)), - item_public=bool(random.randint(0, 1)), - action_labels=[['view', 'download'][random.randint(0, 1)]], - ) - - def _run_date_query(self, time_range_filter): - result = self._run_query({ - 'query': { - 'bool': { - 'filter': { - 'range': { - 'timestamp': time_range_filter, - }, - }, - }, - }, - 'aggs': { - 'by-date': { - 'date_histogram': { - 'field': 'timestamp', - 'interval': 'day', - }, - }, - 'max-timestamp': { - 'max': {'field': 'timestamp'}, - }, - 'min-timestamp': { - 'min': {'field': 'timestamp'}, - }, - }, - }) - return { - 'min': result.aggs['min-timestamp'].value, - 'max': result.aggs['max-timestamp'].value, - **{ - str(bucket.key.date()): bucket.doc_count - for bucket in result.aggs['by-date'] - }, - } - - def _run_query(self, query_dict): - analytics_search = CountedAuthUsage.search().update_from_dict(query_dict) - return analytics_search.execute() diff --git a/osf/management/commands/metrics_backfill_summaries.py b/osf/management/commands/metrics_backfill_summaries.py index 0edd4e6810d..d259e9b2a52 100644 --- a/osf/management/commands/metrics_backfill_summaries.py +++ b/osf/management/commands/metrics_backfill_summaries.py @@ -78,22 +78,20 @@ def _map_download_count(row): def _map_file_summary(row): # date(keen.timestamp) => _source.report_date # "2022-12-30", # keen.created_at => _source.timestamp # "2023-01-02T14:59:04.397056+00:00" - # osfstorage_files_including_quickfiles.total => _source.files.total # 12272, - # osfstorage_files_including_quickfiles.public => _source.files.public # 126, - # osfstorage_files_including_quickfiles.private => _source.files.private # 12146, - # osfstorage_files_including_quickfiles.total_daily => _source.files.total_daily # 0, - # osfstorage_files_including_quickfiles.public_daily => _source.files.public_daily # 0, - # osfstorage_files_including_quickfiles.private_daily => _source.files.private_daily # 0 + # osfstorage_files.private => _source.files.private # 12146, + # osfstorage_files.total_daily => _source.files.total_daily # 0, + # osfstorage_files.public_daily => _source.files.public_daily # 0, + # osfstorage_files.private_daily => _source.files.private_daily # 0 return { 'report_date': _timestamp_to_date(row['keen.timestamp']), 'timestamp': _timestamp_to_dt(row['keen.created_at']), 'files': { - 'total': int(row['osfstorage_files_including_quickfiles.total']), - 'public': int(row['osfstorage_files_including_quickfiles.public']), - 'private': int(row['osfstorage_files_including_quickfiles.private']), - 'total_daily': int(row['osfstorage_files_including_quickfiles.total_daily']), - 'public_daily': int(row['osfstorage_files_including_quickfiles.public_daily']), - 'private_daily': int(row['osfstorage_files_including_quickfiles.private_daily']), + 'total': int(row['osfstorage_files.total']), + 'public': int(row['osfstorage_files.public']), + 'private': int(row['osfstorage_files.private']), + 'total_daily': int(row['osfstorage_files.total_daily']), + 'public_daily': int(row['osfstorage_files.public_daily']), + 'private_daily': int(row['osfstorage_files.private_daily']), }, } diff --git a/osf/management/commands/migrate_notifications.py b/osf/management/commands/migrate_notifications.py new file mode 100644 index 00000000000..6e1786cd880 --- /dev/null +++ b/osf/management/commands/migrate_notifications.py @@ -0,0 +1,229 @@ +import logging +import time +import signal +from contextlib import contextmanager + +from django.contrib.contenttypes.models import ContentType +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from osf.models import NotificationType, NotificationSubscription +from osf.models.notifications import NotificationSubscriptionLegacy +from osf.management.commands.populate_notification_types import populate_notification_types +from tqdm import tqdm + +logger = logging.getLogger(__name__) + +TIMEOUT_SECONDS = 3600 # 60 minutes timeout +BATCH_SIZE = 1000 # Default batch size + +FREQ_MAP = { + 'none': 'none', + 'email_digest': 'weekly', + 'email_transactional': 'instantly', +} + +EVENT_NAME_TO_NOTIFICATION_TYPE = { + # Provider notifications + 'new_pending_withdraw_requests': NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS, + 'contributor_added_preprint': NotificationType.Type.PROVIDER_CONTRIBUTOR_ADDED_PREPRINT, + 'new_pending_submissions': NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS, + 'moderator_added': NotificationType.Type.PROVIDER_MODERATOR_ADDED, + 'reviews_submission_confirmation': NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION, + 'reviews_resubmission_confirmation': NotificationType.Type.PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION, + 'confirm_email_moderation': NotificationType.Type.PROVIDER_CONFIRM_EMAIL_MODERATION, + + # Node notifications + 'file_updated': NotificationType.Type.NODE_FILE_UPDATED, + + # Collection submissions + 'collection_submission_submitted': NotificationType.Type.COLLECTION_SUBMISSION_SUBMITTED, + 'collection_submission_accepted': NotificationType.Type.COLLECTION_SUBMISSION_ACCEPTED, + 'collection_submission_rejected': NotificationType.Type.COLLECTION_SUBMISSION_REJECTED, + 'collection_submission_removed_admin': NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN, + 'collection_submission_removed_moderator': NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_MODERATOR, + 'collection_submission_removed_private': NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_PRIVATE, + 'collection_submission_cancel': NotificationType.Type.COLLECTION_SUBMISSION_CANCEL, +} + + +@contextmanager +def time_limit(seconds): + def signal_handler(signum, frame): + raise TimeoutError('Migration timed out') + + signal.signal(signal.SIGALRM, signal_handler) + signal.alarm(seconds) + try: + yield + finally: + signal.alarm(0) + + +def iter_batches(first_id: int, last_id: int, batch_size: int): + """Yield [start_id, end_id] ranges for batching.""" + for start in range(first_id, last_id + 1, batch_size): + yield start, min(start + batch_size - 1, last_id) + + +def build_existing_keys(): + """Fetch already migrated subscription keys to prevent duplicates.""" + return set( + ( + user_id, + content_type_id, + int(object_id) if object_id else None, + notification_type_id, + ) + for user_id, content_type_id, object_id, notification_type_id in + NotificationSubscription.objects.values_list( + 'user_id', 'content_type_id', 'object_id', 'notification_type_id' + ) + ) + + +def migrate_legacy_notification_subscriptions( + dry_run=False, + batch_size=BATCH_SIZE, + default_frequency='none', + start_id=0, +): + logger.info('Starting legacy notification subscription migration...') + + legacy_qs = NotificationSubscriptionLegacy.objects.filter(id__gte=start_id).order_by('id') + total = legacy_qs.count() + if total == 0: + logger.info('No legacy subscriptions to migrate.') + return + + notiftype_map = dict(NotificationType.objects.values_list('name', 'id')) + existing_keys = build_existing_keys() + + created, skipped = 0, 0 + content_type_cache = {} + + first_id, last_id = legacy_qs.first().id, legacy_qs.last().id + start_time_total = time.time() + + for batch_range in tqdm(list(iter_batches(first_id, last_id, batch_size)), desc='Processing', unit='batch'): + batch = list( + NotificationSubscriptionLegacy.objects + .filter(id__range=batch_range) + .order_by('id') + .select_related('provider', 'node', 'user') + ) + if not batch: + continue + + subscriptions_to_create = [] + + for legacy in batch: + event_name = legacy.event_name + subscribed_object = legacy.provider or legacy.node or legacy.user + if not subscribed_object: + skipped += 1 + continue + + model_class = subscribed_object.__class__ + if model_class not in content_type_cache: + content_type_cache[model_class] = ContentType.objects.get_for_model(model_class) + content_type = content_type_cache[model_class] + + notif_enum = EVENT_NAME_TO_NOTIFICATION_TYPE.get(event_name) + if not notif_enum: + skipped += 1 + continue + + notification_type_id = notiftype_map.get(notif_enum) + if not notification_type_id: + skipped += 1 + continue + + key = ( + legacy.user_id, + content_type.id, + int(subscribed_object.id), + notification_type_id, + ) + if key in existing_keys: + skipped += 1 + continue + + frequency = 'weekly' if getattr(legacy, 'email_digest', False) else default_frequency + + if dry_run: + created += 1 + else: + subscriptions_to_create.append(NotificationSubscription( + notification_type_id=notification_type_id, + user_id=legacy.user_id, + content_type=content_type, + object_id=subscribed_object.id, + message_frequency=frequency, + )) + existing_keys.add(key) + + if not dry_run and subscriptions_to_create: + with transaction.atomic(): + NotificationSubscription.objects.bulk_create( + subscriptions_to_create, + ignore_conflicts=True, + ) + created += len(subscriptions_to_create) + + logger.info(f"Processed batch {batch_range[0]}-{batch_range[1]} (Created: {created}, Skipped: {skipped})") + + elapsed_total = time.time() - start_time_total + logger.info( + f"Migration {'(dry-run) ' if dry_run else ''}completed in {elapsed_total:.2f} seconds: " + f"{created} created, {skipped} skipped." + ) + + +def run_migration(dry_run: bool, batch_size: int, start_id: int): + """Main entry point for command and tests.""" + with time_limit(TIMEOUT_SECONDS): + if not dry_run: + with transaction.atomic(): + logger.info('Populating notification types...') + populate_notification_types(None, {}) + migrate_legacy_notification_subscriptions(dry_run=dry_run, batch_size=batch_size, start_id=start_id) + + +class Command(BaseCommand): + help = 'Migrate legacy NotificationSubscriptionLegacy objects to new Notification app models.' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Run migration in dry-run mode (no DB changes will be committed).' + ) + + parser.add_argument( + '--batch-size', + type=int, + default=BATCH_SIZE, + help=f'Batch size (default: {BATCH_SIZE})' + ) + + parser.add_argument( + '--start-id', + type=int, + default=0, + help='Start migrating from this ID' + ) + + def handle(self, *args, **options): + try: + run_migration( + dry_run=options['dry_run'], + batch_size=options['batch_size'], + start_id=options['start_id'], + ) + except TimeoutError: + logger.error('Migration timed out. Rolling back changes.') + raise CommandError('Migration failed due to timeout') + except Exception as e: + logger.exception('Migration failed. Rolling back changes.') + raise CommandError(str(e)) diff --git a/osf/management/commands/migrate_pagecounter_data.py b/osf/management/commands/migrate_pagecounter_data.py deleted file mode 100644 index 050a355123f..00000000000 --- a/osf/management/commands/migrate_pagecounter_data.py +++ /dev/null @@ -1,124 +0,0 @@ -import datetime -import logging - -from django.core.management.base import BaseCommand -from django.db import connection - -from framework import sentry -from framework.celery_tasks import app as celery_app - -logger = logging.getLogger(__name__) - - -LIMIT_CLAUSE = ' LIMIT %s);' -NO_LIMIT_CLAUSE = ');' - -REVERSE_SQL_BASE = ''' -UPDATE osf_pagecounter PC -SET - resource_id = NULL, - file_id = NULL, - version = NULL, - action = NULL -WHERE PC.id IN ( - SELECT PC.id FROM osf_pagecounter PC - INNER JOIN osf_guid Guid on Guid._id = split_part(PC._id, ':', 2) - INNER JOIN osf_basefilenode File on File._id = split_part(PC._id, ':', 3) -''' -REVERSE_SQL = f'{REVERSE_SQL_BASE} {NO_LIMIT_CLAUSE}' -REVERSE_SQL_LIMITED = f'{REVERSE_SQL_BASE} {LIMIT_CLAUSE}' - -FORWARD_SQL_BASE = ''' - UPDATE osf_pagecounter PC - SET - action = split_part(PC._id, ':', 1), - resource_id = Guid.id, - file_id = File.id, - version = NULLIF(split_part(PC._id, ':', 4), '')::int - FROM osf_guid Guid, osf_basefilenode File - WHERE - Guid._id = split_part(PC._id, ':', 2) AND - File._id = split_part(PC._id, ':', 3) AND - PC.id in ( - select PC.id from osf_pagecounter PC - INNER JOIN osf_guid Guid on Guid._id = split_part(PC._id, ':', 2) - INNER JOIN osf_basefilenode File on File._id = split_part(PC._id, ':', 3) - WHERE (PC.resource_id IS NULL OR PC.file_id IS NULL) -''' -FORWARD_SQL = f'{FORWARD_SQL_BASE} {NO_LIMIT_CLAUSE}' -FORWARD_SQL_LIMITED = f'{FORWARD_SQL_BASE} {LIMIT_CLAUSE}' - -COUNT_SQL = ''' -SELECT count(PC.id) - from osf_pagecounter as PC - INNER JOIN osf_guid Guid on Guid._id = split_part(PC._id, ':', 2) - INNER JOIN osf_basefilenode File on File._id = split_part(PC._id, ':', 3) -where (PC.resource_id IS NULL or PC.file_id IS NULL); -''' - -@celery_app.task(name='management.commands.migrate_pagecounter_data') -def migrate_page_counters(dry_run=False, rows=10000, reverse=False): - script_start_time = datetime.datetime.now() - logger.info(f'Script started time: {script_start_time}') - - sql_query = REVERSE_SQL_LIMITED if reverse else FORWARD_SQL_LIMITED - logger.info(f'SQL Query: {sql_query}') - - with connection.cursor() as cursor: - if not dry_run: - cursor.execute(sql_query, [rows]) - if not reverse: - cursor.execute(COUNT_SQL) - number_of_entries_left = cursor.fetchone()[0] - logger.info(f'Entries left: {number_of_entries_left}') - if number_of_entries_left == 0: - sentry.log_message('Migrate pagecounter data complete') - - script_finish_time = datetime.datetime.now() - logger.info(f'Script finished time: {script_finish_time}') - logger.info(f'Run time {script_finish_time - script_start_time}') - - -class Command(BaseCommand): - help = '''Does the work of the pagecounter migration so that it can be done incrementally when convenient. - You will either need to set the page_size large enough to get all of the records, or you will need to run the - script multiple times until it tells you that it is done.''' - - def add_arguments(self, parser): - parser.add_argument( - '--dry_run', - type=bool, - default=False, - help='Run queries but do not write files', - ) - parser.add_argument( - '--rows', - type=int, - default=10000, - help='How many rows to process during this run', - ) - parser.add_argument( - '--reverse', - type=bool, - default=False, - help='Reverse out the migration', - ) - - # Management command handler - def handle(self, *args, **options): - logger.debug(options) - - dry_run = options['dry_run'] - rows = options['rows'] - reverse = options['reverse'] - logger.debug( - 'Dry run: {}, rows: {}, reverse: {}'.format( - dry_run, - rows, - reverse, - ) - ) - if dry_run: - logger.info('DRY RUN') - - migrate_page_counters(dry_run, rows, reverse) diff --git a/osf/management/commands/migrate_preprint_affiliation.py b/osf/management/commands/migrate_preprint_affiliation.py deleted file mode 100644 index e34c6dc6b27..00000000000 --- a/osf/management/commands/migrate_preprint_affiliation.py +++ /dev/null @@ -1,118 +0,0 @@ -import datetime -import logging - -from django.core.management.base import BaseCommand -from django.db import transaction -from django.db.models import F, Exists, OuterRef - -from osf.models import PreprintContributor, InstitutionAffiliation - -logger = logging.getLogger(__name__) - -AFFILIATION_TARGET_DATE = datetime.datetime(2024, 9, 19, 14, 37, 48, tzinfo=datetime.timezone.utc) - - -class Command(BaseCommand): - """Assign affiliations from users to preprints where they have write or admin permissions, with optional exclusion by user GUIDs.""" - - help = 'Assign affiliations from users to preprints where they have write or admin permissions.' - - def add_arguments(self, parser): - parser.add_argument( - '--exclude-guids', - nargs='+', - dest='exclude_guids', - help='List of user GUIDs to exclude from affiliation assignment' - ) - parser.add_argument( - '--dry-run', - action='store_true', - dest='dry_run', - help='If true, performs a dry run without making changes' - ) - parser.add_argument( - '--batch-size', - type=int, - default=1000, - dest='batch_size', - help='Number of contributors to process in each batch' - ) - - def handle(self, *args, **options): - start_time = datetime.datetime.now() - logger.info(f'Script started at: {start_time}') - - exclude_guids = set(options.get('exclude_guids') or []) - dry_run = options.get('dry_run', False) - batch_size = options.get('batch_size', 1000) - - if dry_run: - logger.info('Dry run mode activated.') - - processed_count, updated_count = assign_affiliations_to_preprints( - exclude_guids=exclude_guids, - dry_run=dry_run, - batch_size=batch_size - ) - - finish_time = datetime.datetime.now() - logger.info(f'Script finished at: {finish_time}') - logger.info(f'Total processed: {processed_count}, Updated: {updated_count}') - logger.info(f'Total run time: {finish_time - start_time}') - - -def assign_affiliations_to_preprints(exclude_guids=None, dry_run=True, batch_size=1000): - exclude_guids = exclude_guids or set() - processed_count = updated_count = 0 - - # Subquery to check if the user has any affiliated institutions - user_has_affiliations = Exists( - InstitutionAffiliation.objects.filter( - user=OuterRef('user') - ) - ) - - contributors_qs = PreprintContributor.objects.filter( - preprint__preprintgroupobjectpermission__permission__codename__in=['write_preprint'], - preprint__preprintgroupobjectpermission__group__user=F('user'), - ).filter( - user_has_affiliations - ).select_related( - 'user', - 'preprint' - ).exclude( - user__guids___id__in=exclude_guids - ).order_by('pk') # Ensure consistent ordering for batching - - total_contributors = contributors_qs.count() - logger.info(f'Total contributors to process: {total_contributors}') - - # Process contributors in batches - with transaction.atomic(): - for offset in range(0, total_contributors, batch_size): - # Use select_for_update() to ensure query hits the primary database - batch_contributors = contributors_qs[offset:offset + batch_size].select_for_update() - - logger.info(f'Processing contributors {offset + 1} to {min(offset + batch_size, total_contributors)}') - - for contributor in batch_contributors: - user = contributor.user - preprint = contributor.preprint - - if preprint.created > AFFILIATION_TARGET_DATE: - continue - - user_institutions = user.get_affiliated_institutions() - processed_count += 1 - if not dry_run: - preprint.affiliated_institutions.add(*user_institutions) - updated_count += 1 - logger.info( - f'Assigned {len(user_institutions)} affiliations from user <{user._id}> to preprint <{preprint._id}>.' - ) - else: - logger.info( - f'Dry run: Would assign {len(user_institutions)} affiliations from user <{user._id}> to preprint <{preprint._id}>.' - ) - - return processed_count, updated_count diff --git a/osf/management/commands/migrate_user_institution_affiliation.py b/osf/management/commands/migrate_user_institution_affiliation.py deleted file mode 100644 index 79170c5ece4..00000000000 --- a/osf/management/commands/migrate_user_institution_affiliation.py +++ /dev/null @@ -1,84 +0,0 @@ -import datetime -import logging - -from django.core.management.base import BaseCommand - -from osf.models import Institution, InstitutionAffiliation - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """Update emails of users from a given affiliated institution (when eligible). - """ - - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--dry', - action='store_true', - dest='dry_run', - help='If true, iterate through eligible users and institutions only' - ) - - def handle(self, *args, **options): - script_start_time = datetime.datetime.now() - logger.info(f'Script started time: {script_start_time}') - - dry_run = options.get('dry_run', False) - if dry_run: - logger.warning('Dry Run: This is a dry-run pass!') - migrate_user_institution_affiliation(dry_run=dry_run) - - script_finish_time = datetime.datetime.now() - logger.info(f'Script finished time: {script_finish_time}') - logger.info(f'Run time {script_finish_time - script_start_time}') - - -def migrate_user_institution_affiliation(dry_run=True): - - institutions = Institution.objects.get_all_institutions() - institution_total = institutions.count() - - institution_count = 0 - user_count = 0 - skipped_user_count = 0 - - for institution in institutions: - institution_count += 1 - user_count_per_institution = 0 - skipped_user_count_per_institution = 0 - users = institution.osfuser_set.all() - user_total_per_institution = users.count() - sso_identity = None - if not institution.delegation_protocol: - sso_identity = InstitutionAffiliation.DEFAULT_VALUE_FOR_SSO_IDENTITY_NOT_AVAILABLE - logger.info(f'Migrating affiliation for <{institution.name}> [{institution_count}/{institution_total}]') - for user in institution.osfuser_set.all(): - user_count_per_institution += 1 - user_count += 1 - logger.info(f'\tMigrating affiliation for <{user._id}::{institution.name}> ' - f'[{user_count_per_institution}/{user_total_per_institution}]') - if not dry_run: - affiliation = user.add_or_update_affiliated_institution( - institution, - sso_identity=sso_identity, - sso_department=user.department - ) - if affiliation: - logger.info(f'\tAffiliation=<{affiliation}> migrated or updated ' - f'for user=<{user._id}> @ institution=<{institution._id}>') - else: - skipped_user_count_per_institution += 1 - skipped_user_count += 1 - logger.info(f'\tSkip migration or update since affiliation exists ' - f'for user=<{user._id}> @ institution=<{institution._id}>') - else: - logger.warning(f'\tDry Run: Affiliation not migrated for {user._id} @ {institution._id}!') - if user_count_per_institution == 0: - logger.warning('No eligible user found') - else: - logger.info(f'Finished migrating affiliation for {user_count_per_institution} users ' - f'@ <{institution.name}>, including {skipped_user_count_per_institution} skipped users') - logger.info(f'Finished migrating affiliation for {user_count} users @ {institution_count} institutions, ' - f'including {skipped_user_count} skipped users') diff --git a/osf/management/commands/move_egap_regs_to_provider.py b/osf/management/commands/move_egap_regs_to_provider.py deleted file mode 100644 index 1dcaa7a6b77..00000000000 --- a/osf/management/commands/move_egap_regs_to_provider.py +++ /dev/null @@ -1,44 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand - -from scripts import utils as script_utils - -logger = logging.getLogger(__name__) - -from osf.models import ( - RegistrationProvider, - RegistrationSchema, - Registration -) - - -def main(dry_run): - egap_provider = RegistrationProvider.objects.get(_id='egap') - egap_schemas = RegistrationSchema.objects.filter(name='EGAP Registration').order_by('-schema_version') - - for egap_schema in egap_schemas: - egap_regs = Registration.objects.filter(registered_schema=egap_schema.id, provider___id='osf') - - if dry_run: - logger.info(f'[DRY RUN] {egap_regs.count()} updated to {egap_provider} with id {egap_provider.id}') - else: - egap_regs.update(provider_id=egap_provider.id) - - -class Command(BaseCommand): - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--dry', - action='store_true', - dest='dry_run', - help='Dry run', - ) - - def handle(self, *args, **options): - dry_run = options.get('dry_run', False) - if not dry_run: - script_utils.add_file_logger(logger, __file__) - - main(dry_run=dry_run) diff --git a/osf/management/commands/populate_branched_from_node.py b/osf/management/commands/populate_branched_from_node.py deleted file mode 100644 index 086f7e4dbef..00000000000 --- a/osf/management/commands/populate_branched_from_node.py +++ /dev/null @@ -1,67 +0,0 @@ -import logging -import datetime - -from django.core.management.base import BaseCommand -from framework.celery_tasks import app as celery_app -from django.db import connection, transaction - -logger = logging.getLogger(__name__) - -POPULATE_BRANCHED_FROM_NODE = """WITH cte AS ( - SELECT id - FROM osf_abstractnode - WHERE type = 'osf.registration' AND - branched_from_node IS null - LIMIT %s -) -UPDATE osf_abstractnode a - SET branched_from_node = CASE WHEN - EXISTS(SELECT id FROM osf_nodelog WHERE action='project_created_from_draft_reg' AND node_id = a.id) THEN False - ELSE True -END -FROM cte -WHERE cte.id = a.id -""" - -@celery_app.task(name='management.commands.populate_branched_from') -def populate_branched_from(page_size=10000, dry_run=False): - with transaction.atomic(): - with connection.cursor() as cursor: - cursor.execute(POPULATE_BRANCHED_FROM_NODE, [page_size]) - if dry_run: - raise RuntimeError('Dry Run -- Transaction rolled back') - -class Command(BaseCommand): - help = '''Populates new deleted field for various models. Ensure you have run migrations - before running this script.''' - - def add_arguments(self, parser): - parser.add_argument( - '--dry_run', - type=bool, - default=False, - help='Run queries but do not write files', - ) - parser.add_argument( - '--page_size', - type=int, - default=10000, - help='How many rows to process at a time', - ) - - def handle(self, *args, **options): - script_start_time = datetime.datetime.now() - logger.info(f'Script started time: {script_start_time}') - logger.debug(options) - - dry_run = options['dry_run'] - page_size = options['page_size'] - - if dry_run: - logger.info('DRY RUN') - - populate_branched_from(page_size, dry_run) - - script_finish_time = datetime.datetime.now() - logger.info(f'Script finished time: {script_finish_time}') - logger.info(f'Run time {script_finish_time - script_start_time}') diff --git a/osf/management/commands/populate_collection_provider_notification_subscriptions.py b/osf/management/commands/populate_collection_provider_notification_subscriptions.py deleted file mode 100644 index 5713b08061b..00000000000 --- a/osf/management/commands/populate_collection_provider_notification_subscriptions.py +++ /dev/null @@ -1,40 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand -from osf.models import NotificationSubscription, CollectionProvider - -logger = logging.getLogger(__file__) - - -def populate_collection_provider_notification_subscriptions(): - for provider in CollectionProvider.objects.all(): - provider_admins = provider.get_group('admin').user_set.all() - provider_moderators = provider.get_group('moderator').user_set.all() - - for subscription in provider.DEFAULT_SUBSCRIPTIONS: - instance, created = NotificationSubscription.objects.get_or_create( - _id=f'{provider._id}_{subscription}', - event_name=subscription, - provider=provider - ) - - if created: - logger.info(f'{provider._id}_{subscription} NotificationSubscription object has been created') - else: - logger.info(f'{provider._id}_{subscription} NotificationSubscription object exists') - - for user in provider_admins | provider_moderators: - # add user to subscription list but set their notification to none by default - instance.add_user_to_subscription(user, 'email_transactional', save=True) - logger.info(f'User {user._id} is subscribed to {provider._id}_{subscription}') - - -class Command(BaseCommand): - help = """ - Creates NotificationSubscriptions for existing RegistrationProvider objects - and adds RegistrationProvider moderators/admins to subscriptions - """ - - # Management command handler - def handle(self, *args, **options): - populate_collection_provider_notification_subscriptions() diff --git a/osf/management/commands/populate_initial_schema_responses.py b/osf/management/commands/populate_initial_schema_responses.py deleted file mode 100644 index 26ba3da7710..00000000000 --- a/osf/management/commands/populate_initial_schema_responses.py +++ /dev/null @@ -1,100 +0,0 @@ -import logging - -from django.core.management.base import BaseCommand -from django.db import transaction -from django.db.models import Exists, F, OuterRef -from framework.celery_tasks import app as celery_app - -from osf.exceptions import PreviousSchemaResponseError, SchemaResponseUpdateError -from osf.models import Registration, SchemaResponse -from osf.utils.workflows import ApprovalStates, RegistrationModerationStates as RegStates - -logger = logging.getLogger(__name__) - -# Initial response pending amin approval or rejected while awaiting it -UNAPPROVED_STATES = [RegStates.INITIAL.db_name, RegStates.REVERTED.db_name] -# Initial response pending moderator approval or rejected while awaiting it -PENDING_MODERATION_STATES = [RegStates.PENDING.db_name, RegStates.REJECTED.db_name] - - -def _update_schema_response_state(schema_response): - '''Set the schema_response's state based on the current state of the parent rgistration.''' - moderation_state = schema_response.parent.moderation_state - if moderation_state in UNAPPROVED_STATES: - schema_response.state = ApprovalStates.UNAPPROVED - elif moderation_state in PENDING_MODERATION_STATES: - schema_response.state = ApprovalStates.PENDING_MODERATION - else: # All remainint states imply initial responses were approved by users at some point - schema_response.state = ApprovalStates.APPROVED - schema_response.save() - - -@celery_app.task(name='management.commands.populate_initial_schema_responses') -@transaction.atomic -def populate_initial_schema_responses(dry_run=False, batch_size=None): - '''Migrate registration_responses into a SchemaResponse for historical registrations.''' - # Find all root registrations that do not yet have SchemaResponses - qs = Registration.objects.prefetch_related('root').annotate( - has_schema_response=Exists(SchemaResponse.objects.filter(nodes__id=OuterRef('id'))) - ).filter( - has_schema_response=False, root=F('id') - ) - if batch_size: - qs = qs[:batch_size] - - count = 0 - for registration in qs: - logger.info( - f'{"[DRY RUN] " if dry_run else ""}' - f'Creating initial SchemaResponse for Registration with guid {registration._id}' - ) - try: - registration.copy_registration_responses_into_schema_response() - except SchemaResponseUpdateError as e: - logger.info( - f'Ignoring unsupported values "registration_responses" for registration ' - f'with guid [{registration._id}]: {str(e)}' - ) - except (ValueError, PreviousSchemaResponseError): - logger.exception( - f'{"[DRY RUN] " if dry_run else ""}' - f'Failure creating SchemaResponse for Registration with guid {registration._id}' - ) - # These errors should have prevented SchemaResponse creation, but better safe than sorry - registration.schema_responses.all().delete() - continue - - _update_schema_response_state(registration.schema_responses.last()) - count += 1 - - logger.info( - f'{"[DRY RUN] " if dry_run else ""}' - f'Created initial SchemaResponses for {count} registrations' - ) - - if dry_run: - raise RuntimeError('Dry run, transaction rolled back') - - return count - - -class Command(BaseCommand): - def add_arguments(self, parser): - super().add_arguments(parser) - parser.add_argument( - '--dry', - action='store_true', - dest='dry_run', - help='Dry run', - ) - - parser.add_argument( - '--batch_size', - type=int, - default=0 - ) - - def handle(self, *args, **options): - dry_run = options.get('dry_run') - batch_size = options.get('batch_size') - populate_initial_schema_responses(dry_run=dry_run, batch_size=batch_size) diff --git a/osf/management/commands/populate_notification_types.py b/osf/management/commands/populate_notification_types.py new file mode 100644 index 00000000000..a9e1f1dc9b2 --- /dev/null +++ b/osf/management/commands/populate_notification_types.py @@ -0,0 +1,78 @@ +import yaml +from django.apps import apps + +from website import settings + +import logging +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 populate_notification_types(*args, **kwargs): + from django.contrib.contenttypes.models import ContentType + from osf.models.notification_type import NotificationType + + 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__', None) + notification_type.pop('tests', None) + object_content_type_model_name = notification_type.pop('object_content_type_model_name') + + 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) + elif object_content_type_model_name == 'draftregistration': + DraftRegistration = apps.get_model('osf', 'DraftRegistration') + content_type = ContentType.objects.get_for_model(DraftRegistration) + 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}') + + template_path = notification_type.pop('template') + if template_path: + with open(template_path) as stream: + template = stream.read() + + nt, _ = NotificationType.objects.update_or_create( + name=notification_type['name'], + defaults=notification_type, + ) + nt.object_content_type = content_type + if not nt.template: + nt.template = template + nt.save() + + +class Command(BaseCommand): + help = 'Population notification types.' + + def handle(self, *args, **options): + with transaction.atomic(): + populate_notification_types(args, options) diff --git a/osf/management/commands/populate_registration_provider_notification_subscriptions.py b/osf/management/commands/populate_registration_provider_notification_subscriptions.py deleted file mode 100644 index fe372fcbb80..00000000000 --- a/osf/management/commands/populate_registration_provider_notification_subscriptions.py +++ /dev/null @@ -1,45 +0,0 @@ -import logging - -from django.contrib.auth.models import Group -from django.core.management.base import BaseCommand -from osf.models import NotificationSubscription, RegistrationProvider - -logger = logging.getLogger(__file__) - - -def populate_registration_provider_notification_subscriptions(): - for provider in RegistrationProvider.objects.all(): - try: - provider_admins = provider.get_group('admin').user_set.all() - provider_moderators = provider.get_group('moderator').user_set.all() - except Group.DoesNotExist: - logger.warning(f'Unable to find groups for provider "{provider._id}", assuming there are no subscriptions to create.') - continue - - for subscription in provider.DEFAULT_SUBSCRIPTIONS: - instance, created = NotificationSubscription.objects.get_or_create( - _id=f'{provider._id}_{subscription}', - event_name=subscription, - provider=provider - ) - - if created: - logger.info(f'{provider._id}_{subscription} NotificationSubscription object has been created') - else: - logger.info(f'{provider._id}_{subscription} NotificationSubscription object exists') - - for user in provider_admins | provider_moderators: - # add user to subscription list but set their notification to none by default - instance.add_user_to_subscription(user, 'email_transactional', save=True) - logger.info(f'User {user._id} is subscribed to {provider._id}_{subscription}') - - -class Command(BaseCommand): - help = """ - Creates NotificationSubscriptions for existing RegistrationProvider objects - and adds RegistrationProvider moderators/admins to subscriptions - """ - - # Management command handler - def handle(self, *args, **options): - populate_registration_provider_notification_subscriptions() diff --git a/osf/management/commands/send_storage_exceeded_announcement.py b/osf/management/commands/send_storage_exceeded_announcement.py index 4cee3ec6573..8c4a687f3ce 100644 --- a/osf/management/commands/send_storage_exceeded_announcement.py +++ b/osf/management/commands/send_storage_exceeded_announcement.py @@ -2,10 +2,9 @@ import json from tqdm import tqdm -from website import mails from django.core.management.base import BaseCommand -from osf.models import Node, OSFUser +from osf.models import Node, OSFUser, NotificationType logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -40,13 +39,15 @@ def main(json_file, dry=False): if public_nodes or private_nodes: if not dry: try: - mails.send_mail( - to_addr=user.username, - mail=mails.STORAGE_CAP_EXCEEDED_ANNOUNCEMENT, + NotificationType.objects.get( + name=NotificationType.Type.USER_STORAGE_CAP_EXCEEDED_ANNOUNCEMENT + ).emit( user=user, - public_nodes=public_nodes, - private_nodes=private_nodes, - can_change_preferences=False, + event_context={ + 'public_nodes': public_nodes, + 'private_nodes': private_nodes, + 'can_change_preferences': False, + } ) except Exception: errors.append(user._id) diff --git a/osf/migrations/0003_aggregated_runsql_calls.py b/osf/migrations/0003_aggregated_runsql_calls.py index 985bed65e86..bf945b0f2dd 100644 --- a/osf/migrations/0003_aggregated_runsql_calls.py +++ b/osf/migrations/0003_aggregated_runsql_calls.py @@ -11,7 +11,6 @@ class Migration(migrations.Migration): migrations.RunSQL( [ """ - CREATE UNIQUE INDEX one_quickfiles_per_user ON public.osf_abstractnode USING btree (creator_id, type, is_deleted) WHERE (((type)::text = 'osf.quickfilesnode'::text) AND (is_deleted = false)); CREATE INDEX osf_abstractnode_collection_pub_del_type_index ON public.osf_abstractnode USING btree (is_public, is_deleted, type) WHERE ((is_public = true) AND (is_deleted = false) AND ((type)::text = 'osf.collection'::text)); CREATE INDEX osf_abstractnode_date_modified_ef1e2ad8 ON public.osf_abstractnode USING btree (last_logged); CREATE INDEX osf_abstractnode_node_pub_del_type_index ON public.osf_abstractnode USING btree (is_public, is_deleted, type) WHERE ((is_public = true) AND (is_deleted = false) AND ((type)::text = 'osf.node'::text)); diff --git a/osf/migrations/0016_auto_20230828_1810.py b/osf/migrations/0016_auto_20230828_1810.py index 50af929ea95..36f056c8ef1 100644 --- a/osf/migrations/0016_auto_20230828_1810.py +++ b/osf/migrations/0016_auto_20230828_1810.py @@ -23,6 +23,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='abstractnode', name='type', - field=models.CharField(choices=[('osf.node', 'node'), ('osf.draftnode', 'draft node'), ('osf.quickfilesnode', 'quick files node'), ('osf.registration', 'registration')], db_index=True, max_length=255), + field=models.CharField(choices=[('osf.node', 'node'), ('osf.draftnode', 'draft node'), ('osf.registration', 'registration')], db_index=True, max_length=255), ), ] diff --git a/osf/migrations/0033_notification_system.py b/osf/migrations/0033_notification_system.py new file mode 100644 index 00000000000..86b2f5e09e5 --- /dev/null +++ b/osf/migrations/0033_notification_system.py @@ -0,0 +1,265 @@ +# Generated by Django 4.2.13 on 2025-08-06 16:41 + +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', '0032_remove_osfgroup_creator_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$$; + """, + reverse_sql=""" + DO $$ + DECLARE + idx record; + BEGIN + FOR idx IN + SELECT indexname + FROM pg_indexes + WHERE tablename = 'osf_notificationsubscription_legacy' + LOOP + IF position('osf_notificationsubscription_legacy' in idx.indexname) > 0 THEN + EXECUTE format( + 'ALTER INDEX %I RENAME TO %I', + idx.indexname, + replace(idx.indexname, 'osf_notificationsubscription_legacy', 'osf_notificationsubscription') + ); + END IF; + END LOOP; + END$$; + """ + ), + migrations.CreateModel( + name='EmailTask', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_id', models.CharField(max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('error_message', models.TextField(blank=True)), + ( + 'status', + models.CharField( + choices=[ + ('PENDING', 'Pending'), + ('NO_USER_FOUND', 'No User Found'), + ('USER_DISABLED', 'User Disabled'), + ('STARTED', 'Started'), + ('SUCCESS', 'Success'), + ('FAILURE', 'Failure'), + ('RETRY', 'Retry'), + ], + default='PENDING', + max_length=20 + ) + ) + ], + ), + 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)), + ], + options={ + 'verbose_name': 'Notification', + 'verbose_name_plural': 'Notifications', + }, + ), + 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)), + ], + options={ + 'db_table': 'osf_notificationsubscription_legacy', + }, + bases=(models.Model, osf.models.base.QuerySetExplainMixin), + ), + 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.RemoveField( + model_name='queuedmail', + name='user', + ), + migrations.AlterModelOptions( + name='notificationsubscription', + options={'verbose_name': 'Notification Subscription', 'verbose_name_plural': 'Notification Subscriptions'}, + ), + migrations.AlterUniqueTogether( + name='notificationsubscription', + unique_together=set(), + ), + migrations.RemoveField( + model_name='abstractnode', + name='child_node_subscriptions', + ), + migrations.RemoveField( + model_name='osfuser', + name='contributor_added_email_records', + ), + migrations.RemoveField( + model_name='osfuser', + name='group_connected_email_records', + ), + migrations.RemoveField( + model_name='osfuser', + name='member_added_email_records', + ), + migrations.AddField( + model_name='notificationsubscription', + name='_is_digest', + field=models.BooleanField(default=False), + ), + 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.DeleteModel( + name='NotificationDigest', + ), + migrations.DeleteModel( + name='QueuedMail', + ), + migrations.AddField( + model_name='notificationsubscriptionlegacy', + name='email_digest', + field=models.ManyToManyField(related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='notificationsubscriptionlegacy', + name='email_transactional', + field=models.ManyToManyField(related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='notificationsubscriptionlegacy', + name='node', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_subscriptions', to='osf.node'), + ), + migrations.AddField( + model_name='notificationsubscriptionlegacy', + name='none', + field=models.ManyToManyField(related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='notificationsubscriptionlegacy', + name='provider', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_subscriptions', to='osf.abstractprovider'), + ), + migrations.AddField( + model_name='notificationsubscriptionlegacy', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notification_subscriptions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='notification', + name='subscription', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='osf.notificationsubscription'), + ), + migrations.AddField( + model_name='emailtask', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + 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.AlterUniqueTogether( + name='notificationsubscriptionlegacy', + unique_together={('_id', 'provider')}, + ), + ] diff --git a/osf/models/__init__.py b/osf/models/__init__.py index 909183adab6..ccf0544f777 100644 --- a/osf/models/__init__.py +++ b/osf/models/__init__.py @@ -8,6 +8,7 @@ ReviewAction, SchemaResponseAction, ) +from .email_task import EmailTask from .admin_log_entry import AdminLogEntry from .admin_profile import AdminProfile from .analytics import UserActivityCounter, PageCounter @@ -62,7 +63,11 @@ from .node_relation import NodeRelation from .nodelog import NodeLog from .notable_domain import NotableDomain, DomainReference -from .notifications import NotificationDigest, NotificationSubscription +from .notifications import NotificationSubscriptionLegacy +from .notification_subscription import NotificationSubscription +from .notification_type import NotificationType +from .notification import Notification + from .oauth import ( ApiOAuth2Application, ApiOAuth2PersonalToken, @@ -80,7 +85,6 @@ RegistrationProvider, WhitelistedSHAREPreprintProvider, ) -from .queued_mail import QueuedMail from .registrations import ( DraftRegistration, DraftRegistrationLog, diff --git a/osf/models/collection_submission.py b/osf/models/collection_submission.py index 893533d85d1..f506bda8856 100644 --- a/osf/models/collection_submission.py +++ b/osf/models/collection_submission.py @@ -1,9 +1,11 @@ import logging from django.db import models +from django.utils import timezone from django.utils.functional import cached_property from framework import sentry from framework.exceptions import PermissionsError +from website.settings import DOMAIN from .base import BaseModel from .mixins import TaxonomizableMixin @@ -11,13 +13,12 @@ from website.util import api_v2_url from website.search.exceptions import SearchUnavailableError from osf.utils.workflows import CollectionSubmissionsTriggers, CollectionSubmissionStates -from website.filters import profile_image_url -from website import mails, settings +from website import settings from osf.utils.machines import CollectionSubmissionMachine +from osf.models.notification_type import NotificationType from django.db.models.signals import post_save from django.dispatch import receiver -from django.utils import timezone logger = logging.getLogger(__name__) @@ -102,72 +103,53 @@ def _notify_contributors_pending(self, event_data): assert str(e) == f'No unclaimed record for user {contributor._id} on node {self.guid.referent._id}' claim_url = None - mails.send_mail( - to_addr=contributor.username, - mail=mails.COLLECTION_SUBMISSION_SUBMITTED(self.creator, self.guid.referent), + NotificationType.Type.COLLECTION_SUBMISSION_SUBMITTED.instance.emit( + is_digest=True, user=contributor, - submitter=user, - is_initator=self.creator == contributor, - is_admin=self.guid.referent.has_permission(contributor, ADMIN), - is_registered_contrib=contributor.is_registered, - collection=self.collection, - claim_url=claim_url, - node=self.guid.referent, - domain=settings.DOMAIN, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + subscribed_object=self, + event_context={ + 'requester_contributor_names': ''.join( + self.guid.referent.contributors.values_list('fullname', flat=True)), + 'localized_timestamp': str(timezone.now()), + 'user_fullname': contributor.fullname, + 'submitter_fullname': user.fullname, + 'requester_fullname': self.creator.fullname, + 'profile_image_url': user.profile_image_url(), + 'submitter_absolute_url': user.get_absolute_url(), + 'collections_link': settings.DOMAIN + 'collections/' + self.collection.provider._id, + 'collections_title': self.collection.title, + 'collection_provider_name': self.collection.provider.name, + 'is_initiator': self.creator == contributor, + 'is_admin': self.guid.referent.has_permission(contributor, ADMIN), + 'is_registered_contrib': contributor.is_registered, + 'claim_url': claim_url, + 'node_title': self.guid.referent.title, + 'node_absolute_url': self.guid.referent.get_absolute_url(), + 'domain': settings.DOMAIN, + 'is_request_email': True, + 'message': f'submitted "{self.guid.referent.title}".', + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + 'reviews_submission_url': f'{DOMAIN}reviews/registries/{self.guid.referent._id}/{self.guid.referent._id}' + }, ) def _notify_moderators_pending(self, event_data): - context = { - 'reviewable': self.guid.referent, - 'abstract_provider': self.collection.provider, - 'reviews_submission_url': f'{settings.DOMAIN}{self.guid.referent._id}?mode=moderator', - 'profile_image_url': profile_image_url( - settings.PROFILE_IMAGE_PROVIDER, - self.creator, - use_ssl=True, - size=settings.PROFILE_IMAGE_MEDIUM - ), - 'message': f'submitted "{self.guid.referent.title}".', - 'allow_submissions': True, - } - - from .notifications import NotificationSubscription - from website.notifications.emails import store_emails - - provider_subscription, created = NotificationSubscription.objects.get_or_create( - _id=f'{self.collection.provider._id}_new_pending_submissions', - provider=self.collection.provider - ) - email_transactors_ids = list( - provider_subscription.email_transactional.all().values_list( - 'guids___id', - flat=True - ) - ) - store_emails( - email_transactors_ids, - 'email_transactional', - 'new_pending_submissions', - self.creator, - self.guid.referent, - timezone.now(), - **context - ) - email_digester_ids = list( - provider_subscription.email_digest.all().values_list( - 'guids___id', - flat=True - ) - ) - store_emails( - email_digester_ids, - 'email_digest', - 'new_pending_submissions', - self.creator, - self.guid.referent, - timezone.now(), - **context + user = event_data.kwargs.get('user', None) + NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.instance.emit( + user=user, + subscribed_object=self.guid.referent, + event_context={ + 'submitter_fullname': self.creator.fullname, + 'requester_fullname': event_data.kwargs.get('user').fullname, + 'requester_contributor_names': ''.join(self.guid.referent.contributors.values_list('fullname', flat=True)), + 'localized_timestamp': str(timezone.now()), + 'message': f'submitted "{self.guid.referent.title}".', + 'reviews_submission_url': f'{DOMAIN}reviews/registries/{self.guid.referent._id}/{self.guid.referent._id}', + 'is_request_email': False, + 'is_initiator': self.creator == user, + 'profile_image_url': user.profile_image_url() + }, + is_digest=True, ) def _validate_accept(self, event_data): @@ -182,16 +164,31 @@ def _validate_accept(self, event_data): def _notify_accepted(self, event_data): if self.collection.provider: for contributor in self.guid.referent.contributors: - mails.send_mail( - to_addr=contributor.username, - mail=mails.COLLECTION_SUBMISSION_ACCEPTED(self.collection, self.guid.referent), + NotificationType.Type.COLLECTION_SUBMISSION_ACCEPTED.instance.emit( user=contributor, - submitter=event_data.kwargs.get('user'), - is_admin=self.guid.referent.has_permission(contributor, ADMIN), - collection=self.collection, - node=self.guid.referent, - domain=settings.DOMAIN, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + subscribed_object=self, + event_context={ + 'requester_contributor_names': ''.join( + self.guid.referent.contributors.values_list('fullname', flat=True)), + 'localized_timestamp': str(timezone.now()), + 'user_fullname': contributor.fullname, + 'requester_fullname': event_data.kwargs.get('user').fullname, + 'submitter_fullname': event_data.kwargs.get('user').fullname, + 'profile_image_url': contributor.profile_image_url(), + 'is_request_email': False, + 'reviews_submission_url': f'{DOMAIN}reviews/preprints/{self.guid.referent.provider._id}/' + f'{self.guid.referent._id}' if self.guid.referent.provider else '', + 'message': f'accepted "{self.guid.referent.title}".', + 'is_admin': self.guid.referent.has_permission(contributor, ADMIN), + 'collection_title': self.collection.title, + 'collection_provider_name': self.collection.provider.name, + 'collection_provider__id': self.collection.provider._id, + 'node_title': self.guid.referent.title, + 'node_absolute_url': self.guid.referent.get_absolute_url(), + 'domain': settings.DOMAIN, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + 'is_initiator': self.creator == contributor, + }, ) def _validate_reject(self, event_data): @@ -209,15 +206,28 @@ def _validate_reject(self, event_data): def _notify_moderated_rejected(self, event_data): for contributor in self.guid.referent.contributors: - mails.send_mail( - to_addr=contributor.username, - mail=mails.COLLECTION_SUBMISSION_REJECTED(self.collection, self.guid.referent), + NotificationType.Type.COLLECTION_SUBMISSION_REJECTED.instance.emit( user=contributor, - is_admin=self.guid.referent.has_permission(contributor, ADMIN), - collection=self.collection, - node=self.guid.referent, - rejection_justification=event_data.kwargs.get('comment'), - osf_contact_email=settings.OSF_CONTACT_EMAIL, + subscribed_object=self, + event_context={ + 'requester_contributor_names': ''.join( + self.guid.referent.contributors.values_list('fullname', flat=True)), + 'localized_timestamp': str(timezone.now()), + 'user_fullname': contributor.fullname, + 'requester_fullname': event_data.kwargs.get('user').fullname, + 'is_admin': self.guid.referent.has_permission(contributor, ADMIN), + 'collection_title': self.collection.title, + 'collection_provider_name': self.collection.provider.name, + 'collections_link': DOMAIN + 'collections/' + self.collection.provider._id, + 'node_absolute_url': self.guid.referent.get_absolute_url(), + 'profile_image_url': contributor.profile_image_url(), + 'message': f'submission of "{self.collection.title} was rejected', + 'node_title': self.guid.referent.title, + 'is_request_email': True, + 'reviews_submission_url': f'{DOMAIN}reviews/registries/{self.guid.referent._id}/{self.guid.referent._id}', + 'rejection_justification': event_data.kwargs.get('comment'), + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + }, ) def _validate_remove(self, event_data): @@ -243,56 +253,92 @@ def _notify_removed(self, event_data): removed_due_to_privacy = event_data.kwargs.get('removed_due_to_privacy') is_moderator = user.has_perm('withdraw_submissions', self.collection.provider) is_admin = self.guid.referent.has_permission(user, ADMIN) + node = self.guid.referent + + event_context_base = { + 'remover_fullname': user.fullname, + 'remover_absolute_url': user.get_absolute_url(), + 'requester_fullname': user.fullname, + 'collections_link': DOMAIN + 'collections/' + self.collection.provider._id if self.collection.provider else None, + 'collection_id': self.collection.id, + 'collection_title': self.collection.title, + 'collection_provider': self.collection.provider.name if self.collection.provider else None, + 'collection_provider_name': self.collection.provider.name if self.collection.provider else None, + 'collection_provider__id': self.collection.provider._id if self.collection.provider else None, + 'node_title': node.title, + 'node_absolute_url': node.absolute_url, + 'profile_image_url': user.profile_image_url(), + 'domain': settings.DOMAIN, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + } + if removed_due_to_privacy and self.collection.provider: if self.is_moderated: for moderator in self.collection.moderators: - mails.send_mail( - to_addr=moderator.username, - mail=mails.COLLECTION_SUBMISSION_REMOVED_PRIVATE(self.collection, self.guid.referent), + NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_PRIVATE.instance.emit( user=moderator, - remover=user, - is_admin=self.guid.referent.has_permission(moderator, ADMIN), - collection=self.collection, - node=self.guid.referent, - domain=settings.DOMAIN, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + event_context={ + **event_context_base, + 'user_fullname': moderator.fullname, + 'is_admin': node.has_permission(moderator, ADMIN), + }, ) - for contributor in self.guid.referent.contributors.all(): - mails.send_mail( - to_addr=contributor.username, - mail=mails.COLLECTION_SUBMISSION_REMOVED_PRIVATE(self.collection, self.guid.referent), + for contributor in node.contributors.all(): + NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_PRIVATE.instance.emit( user=contributor, - remover=user, - is_admin=self.guid.referent.has_permission(contributor, ADMIN), - collection=self.collection, - node=self.guid.referent, - domain=settings.DOMAIN, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + event_context={ + **event_context_base, + 'user_fullname': contributor.fullname, + 'is_admin': node.has_permission(contributor, ADMIN), + }, ) elif is_moderator and self.collection.provider: - for contributor in self.guid.referent.contributors: - mails.send_mail( - to_addr=contributor.username, - mail=mails.COLLECTION_SUBMISSION_REMOVED_MODERATOR(self.collection, self.guid.referent), + for contributor in node.contributors.all(): + NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_MODERATOR.instance.emit( user=contributor, - rejection_justification=event_data.kwargs.get('comment'), - remover=event_data.kwargs.get('user'), - is_admin=self.guid.referent.has_permission(contributor, ADMIN), - collection=self.collection, - node=self.guid.referent, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + event_context={ + **event_context_base, + 'requester_contributor_names': ''.join( + self.guid.referent.contributors.values_list('fullname', flat=True)), + + 'is_admin': node.has_permission(contributor, ADMIN), + 'rejection_justification': event_data.kwargs.get('comment'), + 'collections_title': self.collection.title, + 'collection_provider_name': self.collection.provider.name, + 'collection_provider__id': self.collection.provider._id, + 'remover_absolute_url': user.get_absolute_url() if user is not None else None, + 'node_absolute_url': node.absolute_url, + 'collection_provider': self.collection.provider.name, + 'collections_link': DOMAIN + 'collections/' + self.collection.provider._id, + 'user_fullname': contributor.fullname, + 'is_request_email': False, + 'message': '', + 'localized_timestamp': str(timezone.now()), + 'reviews_submission_url': f'{DOMAIN}reviews/registries/{self.guid.referent._id}/{self.guid.referent._id}', + }, ) elif is_admin and self.collection.provider: - for contributor in self.guid.referent.contributors: - mails.send_mail( - to_addr=contributor.username, - mail=mails.COLLECTION_SUBMISSION_REMOVED_ADMIN(self.collection, self.guid.referent), + for contributor in node.contributors.all(): + NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN.instance.emit( user=contributor, - remover=event_data.kwargs.get('user'), - is_admin=self.guid.referent.has_permission(contributor, ADMIN), - collection=self.collection, - node=self.guid.referent, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + event_context={ + **event_context_base, + 'requester_contributor_names': ''.join( + self.guid.referent.contributors.values_list('fullname', flat=True)), + 'localized_timestamp': str(timezone.now()), + 'user_fullname': contributor.fullname, + 'collections_title': self.collection.title, + 'collection_provider_name': self.collection.provider.name, + 'collection_provider__id': self.collection.provider._id, + 'collection_provider': self.collection.provider.name, + 'collections_link': DOMAIN + 'collections/' + self.collection.provider._id, + 'node_absolute_url': node.get_absolute_url(), + 'is_request_email': False, + 'message': '', + 'is_admin': node.has_permission(contributor, ADMIN), + 'reviews_submission_url': f'{DOMAIN}reviews/registries/{self.guid.referent._id}/{self.guid.referent._id}', + + }, ) def _validate_resubmit(self, event_data): @@ -321,16 +367,44 @@ def _notify_cancel(self, event_data): if force: return - for contributor in self.guid.referent.contributors: - mails.send_mail( - to_addr=contributor.username, - mail=mails.COLLECTION_SUBMISSION_CANCEL(self.collection, self.guid.referent), + user = event_data.kwargs.get('user') # the remover + node = self.guid.referent + + provider = getattr(self.collection, 'provider', None) + if provider: + collections_link = settings.DOMAIN + 'collections/' + provider._id + collection_provider_name = provider.name + else: + collections_link = None # not used in the else branch of the template + collection_provider_name = self.collection.title + + for contributor in node.contributors.all(): + NotificationType.Type.COLLECTION_SUBMISSION_CANCEL.instance.emit( user=contributor, - remover=event_data.kwargs.get('user'), - is_admin=self.guid.referent.has_permission(contributor, ADMIN), - collection=self.collection, - node=self.guid.referent, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + subscribed_object=self.collection, + event_context={ + 'requester_contributor_names': ''.join( + node.contributors.values_list('fullname', flat=True)), + 'profile_image_url': user.profile_image_url(), + 'user_fullname': contributor.fullname, + 'requester_fullname': self.creator.fullname, + 'is_admin': node.has_permission(contributor, ADMIN), + 'node_title': node.title, + 'node_absolute_url': node.get_absolute_url(), + 'remover_fullname': user.fullname if user else '', + 'remover_absolute_url': user.get_absolute_url() if user else '', + 'localized_timestamp': str(timezone.now()), + 'collections_link': collections_link, + 'collection_title': self.collection.title, + 'collection_provider_name': collection_provider_name, + 'node_absolute_url"': node.get_absolute_url(), + 'collection_provider': collection_provider_name, + 'domain': settings.DOMAIN, + 'is_request_email': False, + 'message': '', + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + 'reviews_submission_url': f'{DOMAIN}reviews/registries/{self.guid.referent._id}/{self.guid.referent._id}', + }, ) def _make_public(self, event_data): diff --git a/osf/models/email_task.py b/osf/models/email_task.py new file mode 100644 index 00000000000..f89f2285e5c --- /dev/null +++ b/osf/models/email_task.py @@ -0,0 +1,22 @@ +from django.db import models + +class EmailTask(models.Model): + TASK_STATUS = ( + ('PENDING', 'Pending'), + ('NO_USER_FOUND', 'No User Found'), + ('USER_DISABLED', 'User Disabled'), + ('STARTED', 'Started'), + ('SUCCESS', 'Success'), + ('FAILURE', 'Failure'), + ('RETRY', 'Retry'), + ) + + task_id = models.CharField(max_length=255, unique=True) + user = models.ForeignKey('osf.OSFUser', null=True, on_delete=models.SET_NULL) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + status = models.CharField(max_length=20, choices=TASK_STATUS, default='PENDING') + error_message = models.TextField(blank=True) + + def __str__(self): + return f'{self.task_id} ({self.status})' diff --git a/osf/models/institution.py b/osf/models/institution.py index 5dce3c1df36..35dc82d1880 100644 --- a/osf/models/institution.py +++ b/osf/models/institution.py @@ -14,6 +14,7 @@ from django.utils import timezone from framework import sentry +from osf.models.notification_type import NotificationType from .base import BaseModel, ObjectIDMixin from .contributor import InstitutionalContributor from .institution_affiliation import InstitutionAffiliation @@ -22,7 +23,6 @@ from .storage import InstitutionAssetFile from .validators import validate_email from osf.utils.fields import NonNaiveDateTimeField, LowercaseEmailField -from website import mails from website import settings as website_settings logger = logging.getLogger(__name__) @@ -219,21 +219,16 @@ def _send_deactivation_email(self): attempts = 0 success = 0 for user in self.get_institution_users(): - try: - attempts += 1 - mails.send_mail( - to_addr=user.username, - mail=mails.INSTITUTION_DEACTIVATION, - user=user, - forgot_password_link=f'{website_settings.DOMAIN}{forgot_password}', - osf_support_email=website_settings.OSF_SUPPORT_EMAIL - ) - except Exception as e: - logger.error(f'Failed to send institution deactivation email to user [{user._id}] at [{self._id}]') - sentry.log_exception(e) - continue - else: - success += 1 + attempts += 1 + NotificationType.Type.USER_INSTITUTION_DEACTIVATION.instance.emit( + user=user, + event_context={ + 'user_fullname': user.fullname, + 'forgot_password_link': f'{website_settings.DOMAIN}{forgot_password}', + 'osf_support_email': website_settings.OSF_SUPPORT_EMAIL + } + ) + success += 1 logger.info(f'Institution deactivation notification email has been ' f'sent to [{success}/{attempts}] users for [{self._id}]') diff --git a/osf/models/mixins.py b/osf/models/mixins.py index da5351754d8..a8697a2f69c 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -5,6 +5,7 @@ from django.apps import apps from django.contrib.auth.models import Group, AnonymousUser +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models, transaction from django.utils import timezone @@ -26,6 +27,8 @@ InvalidTagError, BlockedEmailError, ) +from osf.models.notification_type import NotificationType +from osf.models.notification_subscription import NotificationSubscription from .node_relation import NodeRelation from .nodelog import NodeLog from .subject import Subject @@ -54,7 +57,7 @@ from osf.utils.requests import get_request_and_user_id from website.project import signals as project_signals -from website import settings, mails, language +from website import settings, language from website.project.licenses import set_license logger = logging.getLogger(__name__) @@ -306,15 +309,19 @@ def add_affiliated_institution(self, inst, user, log=True, ignore_user_affiliati if not self.is_affiliated_with_institution(inst): self.affiliated_institutions.add(inst) self.update_search() + if notify and getattr(self, 'type', False) == 'osf.node': for user, _ in self.get_admin_contributors_recursive(unique_users=True): - mails.send_mail( - user.username, - mails.PROJECT_AFFILIATION_CHANGED, - **{ - 'user': user, - 'node': self, - }, + NotificationType.Type.NODE_AFFILIATION_CHANGED.instance.emit( + user=user, + subscribed_object=self, + event_context={ + 'domain': settings.DOMAIN, + 'user_fullname': user.fullname, + 'node_title': self.title, + 'node_id': self._id, + 'node_absolute_url': self.get_absolute_url(), + } ) if log: params = self.log_params @@ -345,16 +352,18 @@ def remove_affiliated_institution(self, inst, user, save=False, log=True, notify if save: self.save() self.update_search() - if notify and getattr(self, 'type', False) == 'osf.node': for user, _ in self.get_admin_contributors_recursive(unique_users=True): - mails.send_mail( - user.username, - mails.PROJECT_AFFILIATION_CHANGED, - **{ - 'user': user, - 'node': self, - }, + NotificationType.Type.NODE_AFFILIATION_CHANGED.instance.emit( + user=user, + subscribed_object=self, + event_context={ + 'domain': settings.DOMAIN, + 'user_fullname': user.fullname, + 'node_title': self.title, + 'node_id': self._id, + 'node_absolute_url': self.get_absolute_url(), + } ) return True @@ -1025,7 +1034,9 @@ class Meta: reviews_comments_private = models.BooleanField(null=True, blank=True) reviews_comments_anonymous = models.BooleanField(null=True, blank=True) - DEFAULT_SUBSCRIPTIONS = ['new_pending_submissions'] + DEFAULT_SUBSCRIPTIONS = [ + NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + ] @property def is_reviewed(self): @@ -1070,9 +1081,12 @@ def add_to_group(self, user, group): else: raise TypeError(f"Unsupported group type: {type(group)}") - # Add default notification subscription - for subscription in self.DEFAULT_SUBSCRIPTIONS: - self.add_user_to_subscription(user, f'{self._id}_{subscription}') + NotificationSubscription.objects.get_or_create( + user=user, + content_type=ContentType.objects.get_for_model(self), + object_id=self.id, + notification_type=NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.instance + ) def remove_from_group(self, user, group, unsubscribe=True): _group = self.get_group(group) @@ -1082,22 +1096,20 @@ def remove_from_group(self, user, group, unsubscribe=True): if unsubscribe: # remove notification subscription for subscription in self.DEFAULT_SUBSCRIPTIONS: - self.remove_user_from_subscription(user, f'{self._id}_{subscription}') + self.remove_user_from_subscription(user, subscription) return _group.user_set.remove(user) - def add_user_to_subscription(self, user, subscription_id): - notification = self.notification_subscriptions.get(_id=subscription_id) - user_id = user.id - is_subscriber = notification.none.filter(id=user_id).exists() \ - or notification.email_digest.filter(id=user_id).exists() \ - or notification.email_transactional.filter(id=user_id).exists() - if not is_subscriber: - notification.add_user_to_subscription(user, 'email_transactional', save=True) - - def remove_user_from_subscription(self, user, subscription_id): - notification = self.notification_subscriptions.get(_id=subscription_id) - notification.remove_user_from_subscription(user, save=True) + def remove_user_from_subscription(self, user, subscription): + notification_type = NotificationType.objects.get( + name=subscription, + ) + subscriptions = NotificationSubscription.objects.filter( + notification_type=notification_type, + user=user + ) + if subscriptions: + subscriptions.get().delete() class TaxonomizableMixin(models.Model): @@ -1302,11 +1314,6 @@ def order_by_contributor_field(self): # 'contributor___order', for example raise NotImplementedError() - @property - def contributor_email_template(self): - # default contributor email template as a string - raise NotImplementedError() - def get_addons(self): raise NotImplementedError() @@ -1388,24 +1395,45 @@ def _get_admin_contributors_query(self, users, require_active=True): qs = qs.filter(user__is_active=True) return qs - def add_contributor(self, contributor, permissions=None, visible=True, - send_email=None, auth=None, log=True, save=False, make_curator=False): + def add_contributor( + self, + contributor, + permissions=None, + visible=True, + notification_type=False, + auth=None, + log=True, + save=False, + make_curator=False + ): """Add a contributor to the project. :param User contributor: The contributor to be added :param list permissions: Permissions to grant to the contributor. Array of all permissions if node, highest permission to grant, if contributor, as a string. :param bool visible: Contributor is visible in project dashboard - :param str send_email: Email preference for notifying added contributor + :param str notification_type: Email preference for notifying added contributor :param Auth auth: All the auth information including user, API key :param bool log: Add log to self :param bool save: Save after adding contributor :param bool make_curator indicates whether the user should be an institutional curator :returns: Whether contributor was added """ - send_email = send_email or self.contributor_email_template # If user is merged into another account, use master account contrib_to_add = contributor.merged_by if contributor.is_merged else contributor + if notification_type is None: + from osf.models import AbstractNode, Preprint, DraftRegistration + + if isinstance(self, AbstractNode): + notification_type = NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT + elif isinstance(self, Preprint): + if self.is_published: + notification_type = NotificationType.Type.PREPRINT_CONTRIBUTOR_ADDED_DEFAULT + else: + notification_type = False + elif isinstance(self, DraftRegistration): + notification_type = NotificationType.Type.DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT + if contrib_to_add.is_disabled: raise ValidationValueError('Deactivated users cannot be added as contributors.') @@ -1457,9 +1485,9 @@ def add_contributor(self, contributor, permissions=None, visible=True, if self._id and contrib_to_add: project_signals.contributor_added.send( self, - contributor=contributor, + contributor=contrib_to_add, auth=auth, - email_template=send_email, + notification_type=notification_type, permissions=permissions ) @@ -1469,7 +1497,14 @@ def add_contributor(self, contributor, permissions=None, visible=True, self.update_or_enqueue_on_resource_updated(user_id, first_save=False, saved_fields=['contributors']) return contrib_to_add - def add_contributors(self, contributors, auth=None, log=True, save=False): + def add_contributors( + self, + contributors, + auth=None, + log=True, + save=False, + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT + ): """Add multiple contributors :param list contributors: A list of dictionaries of the form: @@ -1484,8 +1519,13 @@ def add_contributors(self, contributors, auth=None, log=True, save=False): """ for contrib in contributors: self.add_contributor( - contributor=contrib['user'], permissions=contrib['permissions'], - visible=contrib['visible'], auth=auth, log=False, save=False, + contributor=contrib['user'], + permissions=contrib['permissions'], + visible=contrib['visible'], + auth=auth, + log=False, + save=False, + notification_type=notification_type ) if log and contributors: params = self.log_params @@ -1502,8 +1542,16 @@ def add_contributors(self, contributors, auth=None, log=True, save=False): if save: self.save() - def add_unregistered_contributor(self, fullname, email, auth, send_email=None, - visible=True, permissions=None, save=False, existing_user=None): + def add_unregistered_contributor( + self, + fullname, + email, + auth, + notification_type=False, + visible=True, + permissions=None, + existing_user=None + ): """Add a non-registered contributor to the project. :param str fullname: The full name of the person. @@ -1514,7 +1562,6 @@ def add_unregistered_contributor(self, fullname, email, auth, send_email=None, :raises: DuplicateEmailError if user with given email is already in the database. """ OSFUser = apps.get_model('osf.OSFUser') - send_email = send_email or self.contributor_email_template if email: try: @@ -1550,19 +1597,28 @@ def add_unregistered_contributor(self, fullname, email, auth, send_email=None, raise e self.add_contributor( - contributor, permissions=permissions, auth=auth, - visible=visible, send_email=send_email, log=True, save=False + contributor, + permissions=permissions, + auth=auth, + visible=visible, + notification_type=notification_type, + log=True, + save=False ) self._add_related_source_tags(contributor) self.save() return contributor - def add_contributor_registered_or_not(self, auth, user_id=None, - full_name=None, email=None, send_email=None, - permissions=None, bibliographic=True, index=None, save=False): + def add_contributor_registered_or_not(self, + auth, + user_id=None, + full_name=None, + email=None, + notification_type=None, + permissions=None, + bibliographic=True, + index=None): OSFUser = apps.get_model('osf.OSFUser') - send_email = send_email or self.contributor_email_template - if user_id: contributor = OSFUser.load(user_id) if not contributor: @@ -1572,8 +1628,14 @@ def add_contributor_registered_or_not(self, auth, user_id=None, raise ValidationValueError(f'{contributor.fullname} is already a contributor.') if contributor.is_registered: - contributor = self.add_contributor(contributor=contributor, auth=auth, visible=bibliographic, - permissions=permissions, send_email=send_email, save=True) + contributor = self.add_contributor( + contributor=contributor, + auth=auth, + visible=bibliographic, + permissions=permissions, + notification_type=notification_type, + save=True + ) else: if not full_name: raise ValueError( @@ -1581,9 +1643,13 @@ def add_contributor_registered_or_not(self, auth, user_id=None, .format(user_id, self._id) ) contributor = self.add_unregistered_contributor( - fullname=full_name, email=contributor.username, auth=auth, - send_email=send_email, permissions=permissions, - visible=bibliographic, existing_user=contributor, save=True + fullname=full_name, + email=contributor.username, + auth=auth, + notification_type=notification_type, + permissions=permissions, + visible=bibliographic, + existing_user=contributor, ) else: @@ -1592,13 +1658,22 @@ def add_contributor_registered_or_not(self, auth, user_id=None, raise ValidationValueError(f'{contributor.fullname} is already a contributor.') if contributor and contributor.is_registered: - self.add_contributor(contributor=contributor, auth=auth, visible=bibliographic, - send_email=send_email, permissions=permissions, save=True) + self.add_contributor( + contributor=contributor, + auth=auth, + visible=bibliographic, + notification_type=notification_type, + permissions=permissions, + save=True + ) else: contributor = self.add_unregistered_contributor( - fullname=full_name, email=email, auth=auth, - send_email=send_email, permissions=permissions, - visible=bibliographic, save=True + fullname=full_name, + email=email, + auth=auth, + notification_type=notification_type, + permissions=permissions, + visible=bibliographic ) auth.user.email_last_sent = timezone.now() @@ -2237,12 +2312,13 @@ def suspend_spam_user(self, user): user.flag_spam() if not user.is_disabled: user.deactivate_account() - mails.send_mail( - to_addr=user.username, - mail=mails.SPAM_USER_BANNED, - user=user, - osf_support_email=settings.OSF_SUPPORT_EMAIL, - can_change_preferences=False, + NotificationType.Type.USER_SPAM_BANNED.instance.emit( + user, + event_context={ + 'user_fullname': user.fullname, + 'osf_support_email': settings.OSF_SUPPORT_EMAIL, + 'can_change_preferences': False + } ) user.save() diff --git a/osf/models/node.py b/osf/models/node.py index 7aee1cb0880..d020453d72b 100644 --- a/osf/models/node.py +++ b/osf/models/node.py @@ -4,6 +4,7 @@ import re from urllib.parse import urljoin import warnings + from rest_framework import status as http_status import bson @@ -34,6 +35,8 @@ from framework.exceptions import PermissionsError, HTTPError from framework.sentry import log_exception from osf.exceptions import InvalidTagError, NodeStateError, TagNotFoundError, ValidationError +from osf.models.notification_type import NotificationType +from osf.models.notification_subscription import NotificationSubscription from .contributor import Contributor from .collection_submission import CollectionSubmission @@ -320,10 +323,6 @@ class AbstractNode(DirtyFieldsMixin, TypedModel, AddonModelMixin, IdentifierMixi ) SELECT {fields} FROM "{nodelicenserecord}" WHERE id = (SELECT node_license_id FROM ascendants WHERE node_license_id IS NOT NULL) LIMIT 1;""") - # Dictionary field mapping user id to a list of nodes in node.nodes which the user has subscriptions for - # {: [, , ...] } - # TODO: Can this be a reference instead of data? - child_node_subscriptions = DateTimeAwareJSONField(default=dict, blank=True) _contributors = models.ManyToManyField(OSFUser, through=Contributor, related_name='nodes') @@ -941,10 +940,6 @@ def contributors_and_group_members(self): """ return self.get_users_with_perm(READ) - @property - def contributor_email_template(self): - return 'default' - @property def registrations_all(self): """For v1 compat.""" @@ -1255,7 +1250,23 @@ def set_privacy(self, permissions, auth=None, log=True, save=True, meeting_creat if save: self.save() if auth and permissions == 'public': - project_signals.privacy_set_public.send(auth.user, node=self, meeting_creation=meeting_creation) + project_signals.privacy_set_public.send(auth.user, node=self) + existing_sub = NotificationSubscription.objects.filter( + user=auth.user, + notification_type=NotificationType.Type.USER_NEW_PUBLIC_PROJECT.instance, + ) + if not existing_sub: # This is only ever sent once per user. + NotificationType.Type.USER_NEW_PUBLIC_PROJECT.instance.emit( + user=auth.user, + subscribed_object=auth.user, + event_context={ + 'user_fullname': auth.user.fullname, + 'domain': settings.DOMAIN, + 'nid': self._id, + 'project_title': self.title, + }, + save=True + ) return True @property @@ -1335,18 +1346,6 @@ def copy_contributors_from(self, resource): self.add_permission(contrib.user, permission, save=True) Contributor.objects.bulk_create(contribs) - def subscribe_contributors_to_node(self): - """ - Upon registering a DraftNode, subscribe all registered contributors to notifications - - and send emails to users that they have been added to the project. - (DraftNodes are hidden until registration). - """ - for user in self.contributors.filter(is_registered=True): - perm = self.contributor_set.get(user=user).permission - project_signals.contributor_added.send(self, - contributor=user, - auth=None, email_template='default', permissions=perm) - def register_node(self, schema, auth, draft_registration, parent=None, child_ids=None, provider=None, manual_guid=None): """Make a frozen copy of a node. @@ -1555,8 +1554,13 @@ def add_affiliations(self, user, new): for affiliation in user.get_affiliated_institutions(): new.affiliated_institutions.add(affiliation) - # TODO: Optimize me (e.g. use bulk create) - def fork_node(self, auth, title=None, parent=None): + def fork_node( + self, + auth, + title=None, + parent=None, + notification_type=None + ): """Recursively fork a node. :param Auth auth: Consolidated authorization @@ -1564,6 +1568,8 @@ def fork_node(self, auth, title=None, parent=None): :param Node parent: Sets parent, should only be non-null when recursing :return: Forked node """ + if notification_type is None: + notification_type = NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT Registration = apps.get_model('osf.Registration') PREFIX = 'Fork of ' user = auth.user @@ -1673,7 +1679,12 @@ def fork_node(self, auth, title=None, parent=None): forked.save() # Need to call this after save for the notifications to be created with the _primary_key - project_signals.contributor_added.send(forked, contributor=user, auth=auth, email_template='false') + project_signals.contributor_added.send( + forked, + contributor=user, + auth=auth, + notification_type=notification_type + ) return forked @@ -1782,7 +1793,12 @@ def use_as_template(self, auth, changes=None, top_level=True, parent=None): new.save(suppress_log=True) # Need to call this after save for the notifications to be created with the _primary_key - project_signals.contributor_added.send(new, contributor=auth.user, auth=auth, email_template='false') + project_signals.contributor_added.send( + new, + contributor=auth.user, + auth=auth, + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST + ) # Log the creation new.add_log( diff --git a/osf/models/notification.py b/osf/models/notification.py new file mode 100644 index 00000000000..b1b09a6407f --- /dev/null +++ b/osf/models/notification.py @@ -0,0 +1,84 @@ +import logging + +import waffle +from django.db import models +from django.utils import timezone + +from api.base import settings as api_settings +from osf import email, features + + +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', + destination_address=None, + email_context=None, + save=True, + ): + """ + + """ + recipient_address = destination_address or self.subscription.user.username + if not api_settings.CI_ENV: + logging.info( + f"Attempting to send Notification:" + f"\nto={getattr(self.subscription.user, 'username', destination_address)}" + f"\nat={recipient_address}" + f"\ntype={self.subscription.notification_type}" + f"\ncontext={self.event_context}" + f"\nemail_context={email_context}" + ) + if protocol_type == 'email' and waffle.switch_is_active(features.ENABLE_MAILHOG): + email.send_email_over_smtp( + recipient_address, + self.subscription.notification_type, + self.event_context, + email_context + ) + elif protocol_type == 'email': + email.send_email_with_send_grid( + recipient_address, + self.subscription.notification_type, + self.event_context, + email_context + ) + else: + raise NotImplementedError(f'protocol `{protocol_type}` is not supported.') + + if save: + self.mark_sent() + + def mark_sent(self) -> None: + 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 render(self) -> str: + """Render the notification message using the event context.""" + template = self.subscription.notification_type.template + if not template: + raise ValueError('Notification type must have a template to render the notification.') + notification = email.render_notification(template, self.event_context) + return notification + + 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/notification_subscription.py b/osf/models/notification_subscription.py new file mode 100644 index 00000000000..6489e82b96c --- /dev/null +++ b/osf/models/notification_subscription.py @@ -0,0 +1,130 @@ +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 osf.models.notification_type import get_default_frequency_choices +from osf.models.notification import Notification +from api.base import settings + +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') + + _is_digest = models.BooleanField(default=False) + + 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.') + + 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}.') + + def __str__(self) -> str: + return (f'<{self.user} via {self.subscribed_object} subscribes to ' + f'{getattr(self.notification_type, 'name', 'MISSING')} ({self.message_frequency})>') + + class Meta: + verbose_name = 'Notification Subscription' + verbose_name_plural = 'Notification Subscriptions' + + def emit( + self, + event_context=None, + destination_address=None, + email_context=None, + save=True, + ): + """Emit a notification to a user by creating Notification and NotificationSubscription objects. + + Args: + event_context (OSFUser): The info for context for the template + destination_address (optional): overides the user's email address for the notification. Good for sending + to a test address or OSF desk support' + email_context (dict, optional): Context for sending the email bcc, reply_to header etc + """ + if not settings.CI_ENV: + logging.info( + f"Attempting to create Notification:" + f"\nto={getattr(self.user, 'username', destination_address)}" + f"\ntype={self.notification_type.name}" + f"\nmessage_frequency={self.message_frequency}" + f"\nevent_context={event_context}" + f"\nemail_context={email_context}" + + ) + if self.message_frequency == 'instantly': + notification = Notification( + subscription=self, + event_context=event_context + ) + if save: + notification.save() + notification.send( + destination_address=destination_address, + email_context=email_context, + save=save, + ) + 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..2b2a4105246 --- /dev/null +++ b/osf/models/notification_type.py @@ -0,0 +1,246 @@ +from django.db import models +from django.contrib.postgres.fields import ArrayField +from django.contrib.contenttypes.models import ContentType + +from enum import Enum +from osf.utils.caching import cached_property + + +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 + REVIEWS_SUBMISSION_STATUS = 'reviews_submission_status' + ADDONS_BOA_JOB_FAILURE = 'addon_boa_job_failure' + ADDONS_BOA_JOB_COMPLETE = 'addon_boa_job_complete' + + DESK_ARCHIVE_REGISTRATION_STUCK = 'desk_archive_registration_stuck' + 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' + DESK_CROSSREF_ERROR = 'desk_crossref_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_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_COMMENT_REPLIES = 'user_comment_replies' + USER_FILE_UPDATED = 'user_file_updated' + USER_FILE_OPERATION_SUCCESS = 'user_file_operation_success' + USER_FILE_OPERATION_FAILED = 'user_file_operation_failed' + USER_PASSWORD_RESET = 'user_password_reset' + USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_CREATE = 'user_external_login_confirm_email_create' + USER_EXTERNAL_LOGIN_CONFIRM_EMAIL_LINK = 'user_external_login_email_confirm_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' + USER_CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF = 'user_contributor_added_preprint_node_from_osf' + USER_CONTRIBUTOR_ADDED_ACCESS_REQUEST = 'user_contributor_added_access_request' + USER_ARCHIVE_JOB_UNCAUGHT_ERROR = 'user_archive_job_uncaught_error' + USER_NEW_PUBLIC_PROJECT = 'user_new_public_project' + USER_INSTITUTIONAL_ACCESS_REQUEST = 'user_institutional_access_request' + USER_CAMPAIGN_CONFIRM_PREPRINTS_BRANDED = 'user_campaign_confirm_preprint_branded' + USER_CAMPAIGN_CONFIRM_PREPRINTS_OSF = 'user_campaign_confirm_preprint_osf' + USER_CAMPAIGN_CONFIRM_EMAIL_AGU_CONFERENCE = 'user_campaign_confirm_email_agu_conference' + USER_CAMPAIGN_CONFIRM_EMAIL_AGU_CONFERENCE_2023 = 'user_campaign_confirm_email_agu_conference_2023' + USER_CAMPAIGN_CONFIRM_EMAIL_REGISTRIES_OSF = 'user_campaign_confirm_email_registries_osf' + USER_CAMPAIGN_CONFIRM_EMAIL_ERPC = 'user_campaign_confirm_email_erpc' + USER_DIGEST = 'user_digest' + DIGEST_REVIEWS_MODERATORS = 'digest_reviews_moderators' + + # Node notifications + NODE_FILE_UPDATED = 'node_file_updated' + NODE_FILES_UPDATED = 'node_files_updated' + NODE_AFFILIATION_CHANGED = 'node_affiliation_changed' + NODE_REQUEST_ACCESS_SUBMITTED = 'node_request_access_submitted' + NODE_REQUEST_ACCESS_DENIED = 'node_request_access_denied' + NODE_FORK_COMPLETED = 'node_fork_completed' + NODE_FORK_FAILED = 'node_fork_failed' + NODE_INSTITUTIONAL_ACCESS_REQUEST = 'node_institutional_access_request' + NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST = 'node_contributor_added_access_request' + NODE_CONTRIBUTOR_ADDED_DEFAULT = 'node_contributor_added_default' + 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' + NODE_SCHEMA_RESPONSE_REJECTED = 'node_schema_response_rejected' + NODE_SCHEMA_RESPONSE_APPROVED = 'node_schema_response_approved' + NODE_SCHEMA_RESPONSE_SUBMITTED = 'node_schema_response_submitted' + NODE_SCHEMA_RESPONSE_INITIATED = 'node_schema_response_initiated' + NODE_WITHDRAWAl_REQUEST_APPROVED = 'node_withdrawal_request_approved' + NODE_WITHDRAWAl_REQUEST_REJECTED = 'node_withdrawal_request_rejected' + + FILE_UPDATED = 'file_updated' + FILE_ADDED = 'file_added' + FILE_REMOVED = 'file_removed' + ADDON_FILE_COPIED = 'addon_file_copied' + ADDON_FILE_RENAMED = 'addon_file_renamed' + ADDON_FILE_MOVED = 'addon_file_moved' + ADDON_FILE_REMOVED = 'addon_file_removed' + FOLDER_CREATED = 'folder_created' + + # Provider notifications + PROVIDER_NEW_PENDING_SUBMISSIONS = 'provider_new_pending_submissions' + PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS = 'provider_new_pending_withdraw_requests' + PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION = 'provider_reviews_submission_confirmation' + PROVIDER_REVIEWS_MODERATOR_SUBMISSION_CONFIRMATION = 'provider_reviews_moderator_submission_confirmation' + 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' + PREPRINT_CONTRIBUTOR_ADDED_DEFAULT = 'preprint_contributor_added_default' + PREPRINT_PENDING_RETRACTION_ADMIN = 'preprint_pending_retraction_admin' + + # 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' + + REGISTRATION_BULK_UPLOAD_FAILURE_DUPLICATES = 'registration_bulk_upload_failure_duplicates' + + DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT = 'draft_registration_contributor_added_default' + + @cached_property + def instance(self): + obj, created = NotificationType.objects.get_or_create(name=self.value) + return obj + + 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, null=False, blank=False) + + 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=None, + destination_address=None, + subscribed_object=None, + message_frequency='instantly', + event_context=None, + email_context=None, + is_digest=False, + save=True, + ): + """Emit a notification to a user by creating Notification and NotificationSubscription objects. + + Args: + user (OSFUser): The recipient of the notification. + destination_address (optional): For use in case where user's maybe using alternate email addresses. + subscribed_object (optional): The object the subscription is related to. + message_frequency (optional): Initializing message frequency. + event_context (dict, optional): Context for rendering the notification template. + email_context (dict, optional): Context for additional email notification information, so as blind cc etc + """ + from osf.models.notification_subscription import NotificationSubscription + if not save: + subscription = NotificationSubscription( + 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, + message_frequency=message_frequency, + _is_digest=is_digest, + ) + else: + 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': message_frequency}, + _is_digest=is_digest, + ) + subscription.emit( + destination_address=destination_address, + event_context=event_context, + email_context=email_context, + save=save, + ) + + def remove_user_from_subscription(self, user): + """ + """ + from osf.models.notification_subscription import NotificationSubscription + notification, _ = NotificationSubscription.objects.filter( + user=user, + notification_type=self, + ).delete() + + def __str__(self) -> str: + return self.name + + class Meta: + verbose_name = 'Notification Type' + verbose_name_plural = 'Notification Types' diff --git a/osf/models/notifications.py b/osf/models/notifications.py index 86be3424832..be89d26248f 100644 --- a/osf/models/notifications.py +++ b/osf/models/notifications.py @@ -1,15 +1,9 @@ -from django.contrib.postgres.fields import ArrayField from django.db import models -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 +from .base import BaseModel -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,79 +23,4 @@ class NotificationSubscription(BaseModel): class Meta: # Both PreprintProvider and RegistrationProvider default instances use "osf" as their `_id` unique_together = ('_id', 'provider') - - @classmethod - def load(cls, q): - # modm doesn't throw exceptions when loading things that don't exist - try: - return cls.objects.get(_id=q) - except cls.DoesNotExist: - return None - - @property - def owner(self): - # ~100k have owner==user - if self.user is not None: - return self.user - # ~8k have owner=Node - elif self.node is not None: - return self.node - - @owner.setter - def owner(self, value): - if isinstance(value, OSFUser): - self.user = value - elif isinstance(value, Node): - self.node = value - - @property - def absolute_api_v2_url(self): - path = f'/subscriptions/{self._id}/' - return api_v2_url(path) - - def add_user_to_subscription(self, user, notification_type, save=True): - for nt in NOTIFICATION_TYPES: - if getattr(self, nt).filter(id=user.id).exists(): - if nt != notification_type: - getattr(self, nt).remove(user) - else: - if nt == notification_type: - getattr(self, nt).add(user) - - if notification_type != 'none' and isinstance(self.owner, Node) and self.owner.parent_node: - user_subs = self.owner.parent_node.child_node_subscriptions - if self.owner._id not in user_subs.setdefault(user._id, []): - user_subs[user._id].append(self.owner._id) - self.owner.parent_node.save() - - if save: - # Do not clean legacy objects - self.save(clean=False) - - def remove_user_from_subscription(self, user, save=True): - for notification_type in NOTIFICATION_TYPES: - try: - getattr(self, notification_type, []).remove(user) - except ValueError: - pass - - if isinstance(self.owner, Node) and self.owner.parent_node: - try: - self.owner.parent_node.child_node_subscriptions.get(user._id, []).remove(self.owner._id) - self.owner.parent_node.save() - except ValueError: - pass - - 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) - timestamp = NonNaiveDateTimeField() - send_type = models.CharField(max_length=50, db_index=True, validators=[validate_subscription_type, ]) - event = models.CharField(max_length=50) - message = models.TextField() - # TODO: Could this be a m2m with or without an order field? - node_lineage = ArrayField(models.CharField(max_length=31)) + db_table = 'osf_notificationsubscription_legacy' diff --git a/osf/models/preprint.py b/osf/models/preprint.py index 6765aa0a275..afd8ac20110 100644 --- a/osf/models/preprint.py +++ b/osf/models/preprint.py @@ -20,6 +20,7 @@ from framework.auth import Auth from framework.exceptions import PermissionsError, UnpublishedPendingPreprintVersionExists from framework.auth import oauth_scopes +from osf.models.notification_type import NotificationType from .subject import Subject from .tag import Tag @@ -34,14 +35,12 @@ from osf.utils import sanitize from osf.utils.permissions import ADMIN, WRITE from osf.utils.requests import get_request_and_user_id, string_type_request_headers -from website.notifications.emails import get_user_subscriptions -from website.notifications import utils from website.identifiers.clients import CrossRefClient, ECSArXivCrossRefClient from website.project.licenses import set_license from website.util import api_v2_url, api_url_for, web_url_for from website.util.metrics import provider_source_tag from website.citations.utils import datetime_to_csl -from website import settings, mails +from website import settings from website.preprints.tasks import update_or_enqueue_on_preprint_updated from .base import BaseModel, Guid, GuidVersionsThrough, GuidMixinQuerySet, VersionedGuidMixin, check_manually_assigned_guid @@ -588,10 +587,6 @@ def root_folder(self): def osfstorage_region(self): return self.region - @property - def contributor_email_template(self): - return 'preprint' - @property def file_read_scope(self): return oauth_scopes.CoreScopes.PREPRINT_FILE_READ @@ -1031,34 +1026,29 @@ def _add_creator_as_contributor(self): def _send_preprint_confirmation(self, auth): # Send creator confirmation email recipient = self.creator - event_type = utils.find_subscription_type('global_reviews') - user_subscriptions = get_user_subscriptions(recipient, event_type) - if self.provider._id == 'osf': - logo = settings.OSF_PREPRINTS_LOGO - else: - logo = self.provider._id - - context = { - 'domain': settings.DOMAIN, - 'reviewable': self, - 'workflow': self.provider.reviews_workflow, - 'provider_url': '{domain}preprints/{provider_id}'.format( - domain=self.provider.domain or settings.DOMAIN, - provider_id=self.provider._id if not self.provider.domain else '').strip('/'), - 'provider_contact_email': self.provider.email_contact or settings.OSF_CONTACT_EMAIL, - 'provider_support_email': self.provider.email_support or settings.OSF_SUPPORT_EMAIL, - 'no_future_emails': user_subscriptions['none'], - 'is_creator': True, - 'provider_name': 'OSF Preprints' if self.provider.name == 'Open Science Framework' else self.provider.name, - 'logo': logo, - 'document_type': self.provider.preprint_word - } - - mails.send_mail( - recipient.username, - mails.REVIEWS_SUBMISSION_CONFIRMATION, + NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION.instance.emit( + subscribed_object=self.provider, user=recipient, - **context + event_context={ + 'domain': settings.DOMAIN, + 'user_fullname': recipient.fullname, + 'referrer_fullname': recipient.fullname, + 'reviewable_title': self.title, + 'no_future_emails': self.provider.allow_submissions, + 'reviewable_absolute_url': self.absolute_url, + 'reviewable_provider_name': self.provider.name, + 'reviewable_provider__id': self.provider._id, + 'workflow': self.provider.reviews_workflow, + 'provider_url': f'{self.provider.domain or settings.DOMAIN}preprints/' + f'{(self.provider._id if not self.provider.domain else '').strip('/')}', + 'provider_contact_email': self.provider.email_contact or settings.OSF_CONTACT_EMAIL, + 'provider_support_email': self.provider.email_support or settings.OSF_SUPPORT_EMAIL, + 'is_creator': True, + 'provider_name': 'OSF Preprints' if self.provider.name == 'Open Science Framework' else self.provider.name, + 'logo': settings.OSF_PREPRINTS_LOGO if self.provider._id == 'osf' else self.provider._id, + 'document_type': self.provider.preprint_word, + 'notify_comment': not self.provider.reviews_comments_private + }, ) # FOLLOWING BEHAVIOR NOT SPECIFIC TO PREPRINTS diff --git a/osf/models/provider.py b/osf/models/provider.py index aee5ae8fa56..977ff662b42 100644 --- a/osf/models/provider.py +++ b/osf/models/provider.py @@ -14,12 +14,12 @@ from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase from framework import sentry +from osf.models.notification_type import NotificationType from .base import BaseModel, TypedObjectIDMixin from .mixins import ReviewProviderMixin from .brand import Brand from .citation import CitationStyle from .licenses import NodeLicense -from .notifications import NotificationSubscription from .storage import ProviderAssetFile from .subject import Subject from osf.utils.datetime_aware_jsonfield import DateTimeAwareJSONField @@ -252,7 +252,9 @@ def setup_share_source(self, provider_home_page): class CollectionProvider(AbstractProvider): - DEFAULT_SUBSCRIPTIONS = ['new_pending_submissions'] + DEFAULT_SUBSCRIPTIONS = [ + NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + ] class Meta: permissions = ( @@ -292,7 +294,11 @@ class RegistrationProvider(AbstractProvider): REVIEW_STATES = RegistrationModerationStates STATE_FIELD_NAME = 'moderation_state' - DEFAULT_SUBSCRIPTIONS = ['new_pending_submissions', 'new_pending_withdraw_requests'] + DEFAULT_SUBSCRIPTIONS = [ + NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS, + NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS, + + ] # A list of dictionaries describing new fields that providers want to surface on their registrations # Each entry must provide a 'field_name' key. In the future, other keys may be supported to enable @@ -465,19 +471,6 @@ def create_provider_auth_groups(sender, instance, created, **kwargs): instance.update_group_permissions() -@receiver(post_save, sender=CollectionProvider) -@receiver(post_save, sender=PreprintProvider) -@receiver(post_save, sender=RegistrationProvider) -def create_provider_notification_subscriptions(sender, instance, created, **kwargs): - if created: - for subscription in instance.DEFAULT_SUBSCRIPTIONS: - NotificationSubscription.objects.get_or_create( - _id=f'{instance._id}_{subscription}', - event_name=subscription, - provider=instance - ) - - @receiver(post_save, sender=CollectionProvider) def create_primary_collection_for_provider(sender, instance, created, **kwargs): if created: diff --git a/osf/models/queued_mail.py b/osf/models/queued_mail.py deleted file mode 100644 index 844465d5193..00000000000 --- a/osf/models/queued_mail.py +++ /dev/null @@ -1,162 +0,0 @@ -import waffle - -from django.db import models -from django.utils import timezone - -from osf.utils.fields import NonNaiveDateTimeField -from website.mails import Mail, send_mail -from website.mails import presends -from website import settings as osf_settings - -from osf import features -from .base import BaseModel, ObjectIDMixin -from osf.utils.datetime_aware_jsonfield import DateTimeAwareJSONField - - -class QueuedMail(ObjectIDMixin, BaseModel): - user = models.ForeignKey('OSFUser', db_index=True, null=True, on_delete=models.CASCADE) - to_addr = models.CharField(max_length=255) - send_at = NonNaiveDateTimeField(db_index=True, null=False) - - # string denoting the template, presend to be used. Has to be an index of queue_mail types - email_type = models.CharField(max_length=255, db_index=True, null=False) - - # dictionary with variables used to populate mako template and store information used in presends - # Example: - # self.data = { - # 'nid' : 'ShIpTo', - # 'fullname': 'Florence Welch', - #} - data = DateTimeAwareJSONField(default=dict, blank=True) - sent_at = NonNaiveDateTimeField(db_index=True, null=True, blank=True) - - def __repr__(self): - if self.sent_at is not None: - return ''.format( - self._id, self.email_type, self.to_addr, self.sent_at - ) - return ''.format( - self._id, self.email_type, self.to_addr, self.send_at - ) - - def send_mail(self): - """ - Grabs the data from this email, checks for user subscription to help mails, - - constructs the mail object and checks presend. Then attempts to send the email - through send_mail() - :return: boolean based on whether email was sent. - """ - mail_struct = queue_mail_types[self.email_type] - presend = mail_struct['presend'](self) - mail = Mail( - mail_struct['template'], - subject=mail_struct['subject'], - categories=mail_struct.get('categories', None) - ) - self.data['osf_url'] = osf_settings.DOMAIN - if presend and self.user.is_active and self.user.osf_mailing_lists.get(osf_settings.OSF_HELP_LIST): - send_mail(self.to_addr or self.user.username, mail, **(self.data or {})) - self.sent_at = timezone.now() - self.save() - return True - else: - self.__class__.delete(self) - return False - - def find_sent_of_same_type_and_user(self): - """ - Queries up for all emails of the same type as self, sent to the same user as self. - Does not look for queue-up emails. - :return: a list of those emails - """ - return self.__class__.objects.filter(email_type=self.email_type, user=self.user).exclude(sent_at=None) - - -def queue_mail(to_addr, mail, send_at, user, **context): - """ - Queue an email to be sent using send_mail after a specified amount - of time and if the presend returns True. The presend is attached to - the template under mail. - - :param to_addr: the address email is to be sent to - :param mail: the type of mail. Struct following template: - { 'presend': function(), - 'template': mako template name, - 'subject': mail subject } - :param send_at: datetime object of when to send mail - :param user: user object attached to mail - :param context: IMPORTANT kwargs to be attached to template. - Sending mail will fail if needed for template kwargs are - not parameters. - :return: the QueuedMail object created - """ - if waffle.switch_is_active(features.DISABLE_ENGAGEMENT_EMAILS) and mail.get('engagement', False): - return False - new_mail = QueuedMail( - user=user, - to_addr=to_addr, - send_at=send_at, - email_type=mail['template'], - data=context - ) - new_mail.save() - return new_mail - - -# Predefined email templates. Structure: -#EMAIL_TYPE = { -# 'template': the mako template used for email_type, -# 'subject': subject used for the actual email, -# 'categories': categories to attach to the email using Sendgrid's SMTPAPI. -# 'engagement': Whether this is an engagement email that can be disabled with the disable_engagement_emails waffle flag -# 'presend': predicate function that determines whether an email should be sent. May also -# modify mail.data. -#} - -NO_ADDON = { - 'template': 'no_addon', - 'subject': 'Link an add-on to your OSF project', - 'presend': presends.no_addon, - 'categories': ['engagement', 'engagement-no-addon'], - 'engagement': True -} - -NO_LOGIN = { - 'template': 'no_login', - 'subject': 'What you\'re missing on the OSF', - 'presend': presends.no_login, - 'categories': ['engagement', 'engagement-no-login'], - 'engagement': True -} - -NEW_PUBLIC_PROJECT = { - 'template': 'new_public_project', - 'subject': 'Now, public. Next, impact.', - 'presend': presends.new_public_project, - 'categories': ['engagement', 'engagement-new-public-project'], - 'engagement': True -} - - -WELCOME_OSF4M = { - 'template': 'welcome_osf4m', - 'subject': 'The benefits of sharing your presentation', - 'presend': presends.welcome_osf4m, - 'categories': ['engagement', 'engagement-welcome-osf4m'], - 'engagement': True -} - -NO_ADDON_TYPE = 'no_addon' -NO_LOGIN_TYPE = 'no_login' -NEW_PUBLIC_PROJECT_TYPE = 'new_public_project' -WELCOME_OSF4M_TYPE = 'welcome_osf4m' - - -# Used to keep relationship from stored string 'email_type' to the predefined queued_email objects. -queue_mail_types = { - NO_ADDON_TYPE: NO_ADDON, - NO_LOGIN_TYPE: NO_LOGIN, - NEW_PUBLIC_PROJECT_TYPE: NEW_PUBLIC_PROJECT, - WELCOME_OSF4M_TYPE: WELCOME_OSF4M -} diff --git a/osf/models/registrations.py b/osf/models/registrations.py index d74260358f4..4b2b75fc324 100644 --- a/osf/models/registrations.py +++ b/osf/models/registrations.py @@ -23,6 +23,7 @@ from osf.exceptions import NodeStateError, DraftRegistrationStateError from osf.external.internet_archive.tasks import archive_to_ia, update_ia_metadata from osf.metrics import RegistriesModerationMetrics +from osf.models.notification_type import NotificationType from .action import RegistrationAction from .archive import ArchiveJob from .contributor import DraftRegistrationContributor @@ -661,7 +662,10 @@ def retract_registration(self, user, justification=None, save=True, moderator_in f'User {user} does not have moderator privileges on Provider {self.provider}') retraction = self._initiate_retraction( - user, justification, moderator_initiated=moderator_initiated) + user, + justification, + moderator_initiated=moderator_initiated + ) self.retraction = retraction self.registered_from.add_log( action=NodeLog.RETRACTION_INITIATED, @@ -1245,11 +1249,6 @@ def visible_contributors(self): draftregistrationcontributor__visible=True ).order_by(self.order_by_contributor_field) - @property - def contributor_email_template(self): - # Override for ContributorMixin - return 'draft_registration' - @property def institutions_url(self): # For NodeInstitutionsRelationshipSerializer @@ -1326,7 +1325,15 @@ def system_tags(self): return self.all_tags.filter(system=True).values_list('name', flat=True) @classmethod - def create_from_node(cls, user, schema, node=None, data=None, provider=None): + def create_from_node( + cls, + user, + schema, + node=None, + data=None, + provider=None, + notification_type=NotificationType.Type.DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT + ): if not provider: provider = RegistrationProvider.get_default() @@ -1368,7 +1375,7 @@ def create_from_node(cls, user, schema, node=None, data=None, provider=None): draft, contributor=user, auth=None, - email_template='draft_registration', + notification_type=notification_type, permissions=initiator_permissions ) diff --git a/osf/models/sanctions.py b/osf/models/sanctions.py index 6d8b904b4b9..bacf1532b83 100644 --- a/osf/models/sanctions.py +++ b/osf/models/sanctions.py @@ -8,7 +8,6 @@ from framework.auth import Auth from framework.exceptions import PermissionsError from website import settings as osf_settings -from website import mails from osf.exceptions import ( InvalidSanctionRejectionToken, InvalidSanctionApprovalToken, @@ -20,6 +19,7 @@ from osf.utils import tokens from osf.utils.machines import ApprovalsMachine from osf.utils.workflows import ApprovalStates, SanctionTypes +from osf.models.notification_type import NotificationType VIEW_PROJECT_URL_TEMPLATE = osf_settings.DOMAIN + '{node_id}/' @@ -345,8 +345,6 @@ class Meta: class EmailApprovableSanction(TokenApprovableSanction): - AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = None - NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = None VIEW_URL_TEMPLATE = '' APPROVE_URL_TEMPLATE = '' @@ -396,30 +394,15 @@ def _rejection_url(self, user_id): def _rejection_url_context(self, user_id): return None - def _send_approval_request_email(self, user, template, context): - mails.send_mail(user.username, template, user=user, can_change_preferences=False, **context) + def _send_approval_request_email(self, user, notification_type, context): + notification_type.instance.emit( + user=user, + event_context=context + ) def _email_template_context(self, user, node, is_authorizer=False): return {} - def _notify_authorizer(self, authorizer, node): - context = self._email_template_context(authorizer, - node, - is_authorizer=True) - if self.AUTHORIZER_NOTIFY_EMAIL_TEMPLATE: - self._send_approval_request_email( - authorizer, self.AUTHORIZER_NOTIFY_EMAIL_TEMPLATE, context) - else: - raise NotImplementedError() - - def _notify_non_authorizer(self, user, node): - context = self._email_template_context(user, node) - if self.NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE: - self._send_approval_request_email( - user, self.NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE, context) - else: - raise NotImplementedError - def ask(self, group): """ :param list group: List of (user, node) tuples containing contributors to notify about the @@ -429,9 +412,19 @@ def ask(self, group): return for contrib, node in group: if contrib._id in self.approval_state: - self._notify_authorizer(contrib, node) + return self.AUTHORIZER_NOTIFY_EMAIL_TYPE.instance.emit( + user=contrib, + event_context=self._email_template_context( + contrib, + node, + is_authorizer=True + ) + ) else: - self._notify_non_authorizer(contrib, node) + return self.NON_AUTHORIZER_NOTIFY_EMAIL_TYPE.instance.emit( + user=contrib, + event_context=self._email_template_context(contrib, node) + ) def add_authorizer(self, user, node, **kwargs): super().add_authorizer(user, node, **kwargs) @@ -467,8 +460,8 @@ class Embargo(SanctionCallbackMixin, EmailApprovableSanction): DISPLAY_NAME = 'Embargo' SHORT_NAME = 'embargo' - AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_ADMIN - NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_EMBARGO_ADMIN + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_EMBARGO_NON_ADMIN VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -549,25 +542,45 @@ def _email_template_context(self, registration = self._get_registration() context.update({ + 'domain': osf_settings.DOMAIN, 'is_initiator': self.initiated_by == user, - 'initiated_by': self.initiated_by.fullname, + 'initiated_by_fullname': self.initiated_by.fullname, 'approval_link': approval_link, + 'user_fullname': user.fullname, 'project_name': registration.title, 'disapproval_link': disapproval_link, 'registration_link': registration_link, - 'embargo_end_date': self.end_date, + 'embargo_end_date': str(self.end_date), 'approval_time_span': approval_time_span, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), + 'reviewable_title': self._get_registration().title, + 'reviewable__id': self._get_registration()._id, + 'reviewable_absolute_url': self._get_registration().absolute_url, + 'reviewable_provider_name': self._get_registration().provider.name, + 'reviewable_provider': self._get_registration().provider.name, + 'reviewable_provider__id': self._get_registration().provider._id, + 'reviewable_registered_from_absolute_url': self._get_registration().registered_from.absolute_url, + 'reviewable_withdrawal_justification': self._get_registration().withdrawal_justification, + 'reviewable_branched_from_node': self._get_registration().branched_from_node }) else: context.update({ - 'initiated_by': self.initiated_by.fullname, + 'domain': osf_settings.DOMAIN, + 'user_fullname': user.fullname, + 'initiated_by_fullname': self.initiated_by.fullname, 'registration_link': registration_link, - 'embargo_end_date': self.end_date, + 'embargo_end_date': str(self.end_date), 'approval_time_span': approval_time_span, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), + 'reviewable_title': self._get_registration().title, + 'reviewable__id': self._get_registration()._id, + 'reviewable_absolute_url': self._get_registration().absolute_url, + 'reviewable_provider_name': self._get_registration().provider.name, + 'reviewable_provider__id': self._get_registration().provider._id, + 'reviewable_provider': self._get_registration().provider.name, + 'reviewable_registered_from_absolute_url': self._get_registration().registered_from.absolute_url, + 'reviewable_withdrawal_justification': self._get_registration().withdrawal_justification, + 'reviewable_branched_from_node': self._get_registration().branched_from_node }) return context @@ -647,8 +660,8 @@ class Retraction(EmailApprovableSanction): DISPLAY_NAME = 'Retraction' SHORT_NAME = 'retraction' - AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_ADMIN - NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_RETRACTION_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_RETRACTION_ADMIN + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_RETRACTION_NON_ADMIN VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -707,23 +720,41 @@ def _email_template_context(self, user, node, is_authorizer=False, urls=None): disapproval_link = urls.get('reject', '') return { + 'domain': osf_settings.DOMAIN, 'is_initiator': self.initiated_by == user, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), - 'initiated_by': self.initiated_by.fullname, + 'reviewable_title': self._get_registration().title, + 'reviewable__id': self._get_registration()._id, + 'reviewable_provider_name': self._get_registration().provider.name, + 'reviewable_provider__id': self._get_registration().provider._id, + 'reviewable_absolute_url': self._get_registration().absolute_url, + 'reviewable_withdrawal_justification': self._get_registration().withdrawal_justification, + 'reviewable_registered_from_absolute_url': self._get_registration().registered_from.absolute_url, + 'initiated_by_fullname': self.initiated_by.fullname, 'project_name': self.registrations.filter().values_list('title', flat=True).get(), 'registration_link': registration_link, 'approval_link': approval_link, 'disapproval_link': disapproval_link, 'approval_time_span': approval_time_span, + 'user_fullname': user.fullname, + 'reviewable_branched_from_node': self._get_registration().branched_from_node } else: return { - 'initiated_by': self.initiated_by.fullname, + 'domain': osf_settings.DOMAIN, + 'initiated_by_fullname': self.initiated_by.fullname, 'registration_link': registration_link, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), + 'reviewable_title': self._get_registration().title, + 'reviewable__id': self._get_registration()._id, + 'reviewable_provider_name': self._get_registration().provider.name, + 'reviewable_provider__id': self._get_registration().provider._id, + 'reviewable_absolute_url': self._get_registration().absolute_url, + 'reviewable_withdrawal_justification': self._get_registration().withdrawal_justification, + 'reviewable_registered_from_absolute_url': self._get_registration().registered_from.absolute_url, 'approval_time_span': approval_time_span, + 'user_fullname': user.fullname, + 'reviewable_branched_from_node': self._get_registration().branched_from_node } def _on_reject(self, event_data): @@ -767,8 +798,8 @@ class RegistrationApproval(SanctionCallbackMixin, EmailApprovableSanction): DISPLAY_NAME = 'Approval' SHORT_NAME = 'registration_approval' - AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_REGISTRATION_ADMIN - NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_REGISTRATION_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_REGISTRATION_ADMIN + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_REGISTRATION_NON_ADMIN VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -833,10 +864,18 @@ def _email_template_context(self, user, node, is_authorizer=False, urls=None): disapproval_link = urls.get('reject', '') registration = self._get_registration() context.update({ + 'domain': osf_settings.DOMAIN, 'is_initiator': self.initiated_by == user, - 'initiated_by': self.initiated_by.fullname, + 'user_fullname': user.fullname, + 'initiated_by_fullname': self.initiated_by.fullname, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), + 'reviewable_title': self._get_registration().title, + 'reviewable_absolute_url': self._get_registration().absolute_url, + 'reviewable_registered_from_absolute_url': self._get_registration().registered_from.absolute_url, + 'reviewable_branched_from_node': self._get_registration().branched_from_node, + 'reviewable_provider_name': self._get_registration().provider.name, + 'reviewable__id': self._get_registration()._id, + 'reviewable_provider__id': self._get_registration().provider._id, 'registration_link': registration_link, 'approval_link': approval_link, 'disapproval_link': disapproval_link, @@ -845,11 +884,18 @@ def _email_template_context(self, user, node, is_authorizer=False, urls=None): }) else: context.update({ - 'initiated_by': self.initiated_by.fullname, + 'domain': osf_settings.DOMAIN, + 'user_fullname': user.fullname, + 'initiated_by_fullname': self.initiated_by.fullname, 'registration_link': registration_link, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), + 'reviewable_title': self._get_registration().title, + 'reviewable_absolute_url': self._get_registration().absolute_url, + 'reviewable_branched_from_node': self._get_registration().branched_from_node, + 'reviewable_provider_name': self._get_registration().provider.name, + 'reviewable_provider__id': self._get_registration().provider._id, 'approval_time_span': approval_time_span, + 'reviewable_registered_from_absolute_url': self._get_registration().registered_from.absolute_url, }) return context @@ -932,8 +978,8 @@ class EmbargoTerminationApproval(EmailApprovableSanction): DISPLAY_NAME = 'Embargo Termination Request' SHORT_NAME = 'embargo_termination_approval' - AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_TERMINATION_ADMIN - NON_AUTHORIZER_NOTIFY_EMAIL_TEMPLATE = mails.PENDING_EMBARGO_TERMINATION_NON_ADMIN + AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_EMBARGO_TERMINATION_ADMIN + NON_AUTHORIZER_NOTIFY_EMAIL_TYPE = NotificationType.Type.NODE_PENDING_EMBARGO_TERMINATION_NON_ADMIN VIEW_URL_TEMPLATE = VIEW_PROJECT_URL_TEMPLATE APPROVE_URL_TEMPLATE = osf_settings.DOMAIN + 'token_action/{node_id}/?token={token}' @@ -986,33 +1032,45 @@ def _email_template_context(self, user, node, is_authorizer=False, urls=None): urls = urls or self.stashed_urls.get(user._id, {}) registration_link = urls.get('view', self._view_url(user._id, node)) approval_time_span = osf_settings.EMBARGO_TERMINATION_PENDING_TIME.days * 24 + registration = self._get_registration() + if is_authorizer: approval_link = urls.get('approve', '') disapproval_link = urls.get('reject', '') - registration = self._get_registration() - context.update({ + 'domain': osf_settings.DOMAIN, 'is_initiator': self.initiated_by == user, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), - 'initiated_by': self.initiated_by.fullname, + 'reviewable_title': registration.title, + 'reviewable__id': self._get_registration()._id, + 'reviewable_provider_name': self._get_registration().provider.name, + 'reviewable_absolute_url': registration.absolute_url, + 'reviewable_provider__id': registration.provider._id, + 'initiated_by_fullname': self.initiated_by.fullname, 'approval_link': approval_link, 'project_name': registration.title, 'disapproval_link': disapproval_link, 'registration_link': registration_link, 'embargo_end_date': self.end_date, 'approval_time_span': approval_time_span, + 'user_fullname': user.fullname, }) else: context.update({ - 'initiated_by': self.initiated_by.fullname, + 'domain': osf_settings.DOMAIN, + 'initiated_by_fullname': self.initiated_by.fullname, 'project_name': self.target_registration.title, 'registration_link': registration_link, 'embargo_end_date': self.end_date, 'is_moderated': self.is_moderated, - 'reviewable': self._get_registration(), + 'reviewable_title': registration.title, + 'reviewable__id': self._get_registration()._id, + 'reviewable_provider__id': registration.provider._id, + 'reviewable_absolute_url': registration.absolute_url, + 'reviewable_provider_name': self.target_registration.provider.name, 'approval_time_span': approval_time_span, + 'user_fullname': user.fullname, }) return context diff --git a/osf/models/schema_response.py b/osf/models/schema_response.py index 4fa5289f2d4..910bc68ac20 100644 --- a/osf/models/schema_response.py +++ b/osf/models/schema_response.py @@ -9,6 +9,7 @@ from framework.exceptions import PermissionsError from osf.exceptions import PreviousSchemaResponseError, SchemaResponseStateError, SchemaResponseUpdateError +from osf.models.notification_type import NotificationType from .base import BaseModel, ObjectIDMixin from .metaschema import RegistrationSchemaBlock from .schema_response_block import SchemaResponseBlock @@ -17,18 +18,14 @@ from osf.utils.machines import ApprovalsMachine from osf.utils.workflows import ApprovalStates, SchemaResponseTriggers -from website.mails import mails from website.reviews.signals import reviews_email_submit_moderators_notifications -from website.settings import DOMAIN +from website.settings import ( + DOMAIN, + REGISTRATION_UPDATE_APPROVAL_TIME, + REGISTRATION_APPROVAL_TIME, +) -EMAIL_TEMPLATES_PER_EVENT = { - 'create': mails.SCHEMA_RESPONSE_INITIATED, - 'submit': mails.SCHEMA_RESPONSE_SUBMITTED, - 'accept': mails.SCHEMA_RESPONSE_APPROVED, - 'reject': mails.SCHEMA_RESPONSE_REJECTED, -} - class SchemaResponse(ObjectIDMixin, BaseModel): '''Collects responses for a schema associated with a parent object. @@ -207,6 +204,7 @@ def create_from_previous_response(cls, initiator, previous_response, justificati ) new_response.save() new_response.response_blocks.add(*previous_response.response_blocks.all()) + new_response._notify_users(event='create', event_initiator=initiator) return new_response @@ -480,31 +478,51 @@ def _notify_users(self, event, event_initiator): if self.state is ApprovalStates.PENDING_MODERATION: email_context = notifications.get_email_template_context(resource=self.parent) email_context['revision_id'] = self._id - email_context['referrer'] = self.initiator + + email_context['requester_contributor_names'] = ''.join( + self.parent.contributors.values_list('fullname', flat=True)), reviews_email_submit_moderators_notifications.send( - timestamp=timezone.now(), context=email_context + timestamp=timezone.now(), + context=email_context, + resource=self.parent ) - template = EMAIL_TEMPLATES_PER_EVENT.get(event) - if not template: + notification_type = { + 'create': NotificationType.Type.NODE_SCHEMA_RESPONSE_INITIATED.instance, + 'submit': NotificationType.Type.NODE_SCHEMA_RESPONSE_SUBMITTED.instance, + 'accept': NotificationType.Type.NODE_SCHEMA_RESPONSE_APPROVED.instance, + 'reject': NotificationType.Type.NODE_SCHEMA_RESPONSE_REJECTED.instance, + }.get(event) + if not notification_type: return - email_context = { + event_context = { 'resource_type': self.parent.__class__.__name__.lower(), 'title': self.parent.title, + 'registration_approval_time': int(REGISTRATION_APPROVAL_TIME.total_seconds() / 3600), + 'registration_update_approval_time': int(REGISTRATION_UPDATE_APPROVAL_TIME.total_seconds() / 3600), 'parent_url': self.parent.absolute_url, 'update_url': self.absolute_url, - 'initiator': event_initiator.fullname if event_initiator else None, + 'initiator_fullname': event_initiator.fullname if event_initiator else None, 'pending_moderation': self.state is ApprovalStates.PENDING_MODERATION, + 'domain': DOMAIN, 'provider': self.parent.provider.name if self.parent.provider else '', + 'requester_contributor_names': ''.join( + self.parent.contributors.values_list('fullname', flat=True)), } - for contributor, _ in self.parent.get_active_contributors_recursive(unique_users=True): - email_context['user'] = contributor - email_context['can_write'] = self.parent.has_permission(contributor, 'write') - email_context['is_approver'] = contributor in self.pending_approvers.all(), - email_context['is_initiator'] = contributor == event_initiator - mails.send_mail(to_addr=contributor.username, mail=template, **email_context) + event_context.update( + { + 'can_write': self.parent.has_permission(contributor, 'write'), + 'is_approver': contributor in self.pending_approvers.all(), + 'user_fullname': contributor.fullname, + 'is_initiator': contributor == event_initiator, + } + ) + notification_type.emit( + user=contributor, + event_context=event_context + ) def _is_updated_response(response_block, new_response): diff --git a/osf/models/user.py b/osf/models/user.py index 79e11f34ebd..d0c3cf22b2d 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -57,11 +57,12 @@ from osf.utils.requests import check_select_for_update from osf.utils.permissions import API_CONTRIBUTOR_PERMISSIONS, MANAGER, MEMBER, ADMIN from website import settings as website_settings -from website import filters, mails +from website import filters from website.project import new_bookmark_collection from website.util.metrics import OsfSourceTags, unregistered_created_source_tag from importlib import import_module from osf.utils.requests import get_headers_from_request +from osf.models.notification_type import NotificationType SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -225,20 +226,6 @@ class OSFUser(DirtyFieldsMixin, GuidMixin, BaseModel, AbstractBaseUser, Permissi # ... # } - # Time of last sent notification email to newly added contributors - # Format : { - # : { - # 'last_sent': time.time() - # } - # ... - # } - contributor_added_email_records = DateTimeAwareJSONField(default=dict, blank=True) - - # Tracks last email sent where user was added to an OSF Group - member_added_email_records = DateTimeAwareJSONField(default=dict, blank=True) - # Tracks last email sent where an OSF Group was connected to a node - group_connected_email_records = DateTimeAwareJSONField(default=dict, blank=True) - # The user into which this account was merged merged_by = models.ForeignKey('self', null=True, blank=True, related_name='merger', on_delete=models.CASCADE) @@ -1071,12 +1058,16 @@ def set_password(self, raw_password, notify=True): raise ChangePasswordError(['Password cannot be the same as your email address']) super().set_password(raw_password) if had_existing_password and notify: - mails.send_mail( - to_addr=self.username, - mail=mails.PASSWORD_RESET, + NotificationType.Type.USER_PASSWORD_RESET.instance.emit( + subscribed_object=self, user=self, - can_change_preferences=False, - osf_contact_email=website_settings.OSF_CONTACT_EMAIL + message_frequency='instantly', + event_context={ + 'domain': website_settings.DOMAIN, + 'user_fullname': self.fullname, + 'can_change_preferences': False, + 'osf_contact_email': website_settings.OSF_CONTACT_EMAIL + } ) remove_sessions_for_user(self) diff --git a/osf/models/user_message.py b/osf/models/user_message.py index ac77cefe629..10ea735b61e 100644 --- a/osf/models/user_message.py +++ b/osf/models/user_message.py @@ -3,8 +3,9 @@ from django.db.models.signals import post_save from django.dispatch import receiver +from osf.models.notification_type import NotificationType from .base import BaseModel, ObjectIDMixin -from website.mails import send_mail, USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST +from website import settings class MessageTypes(models.TextChoices): @@ -20,7 +21,7 @@ class MessageTypes(models.TextChoices): INSTITUTIONAL_REQUEST = ('institutional_request', 'INSTITUTIONAL_REQUEST') @classmethod - def get_template(cls: Type['MessageTypes'], message_type: str) -> str: + def get_notification_type(cls: Type['MessageTypes'], message_type: str) -> str: """ Retrieve the email template associated with a specific message type. @@ -31,7 +32,7 @@ def get_template(cls: Type['MessageTypes'], message_type: str) -> str: str: The email template string for the specified message type. """ return { - cls.INSTITUTIONAL_REQUEST: USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST + cls.INSTITUTIONAL_REQUEST: NotificationType.Type.USER_INSTITUTIONAL_ACCESS_REQUEST }[message_type] @@ -84,18 +85,19 @@ def send_institution_request(self) -> None: """ Sends an institutional access request email to the recipient of the message. """ - send_mail( - mail=MessageTypes.get_template(self.message_type), - to_addr=self.recipient.username, - bcc_addr=[self.sender.username] if self.is_sender_BCCed else None, - reply_to=self.sender.username if self.reply_to else None, + MessageTypes.get_notification_type(self.message_type).instance.emit( user=self.recipient, - **{ - 'sender': self.sender, - 'recipient': self.recipient, + event_context={ + 'recipient_fullname': self.recipient.fullname, 'message_text': self.message_text, - 'institution': self.institution, + 'institution_name': self.institution.name, + 'domain': settings.DOMAIN, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, }, + email_context={ + 'bcc_addr': [self.sender.username] if self.is_sender_BCCed else None, + 'reply_to': self.sender.username if self.reply_to else None, + } ) diff --git a/osf/models/validators.py b/osf/models/validators.py index 87f00f826a6..29ee184b66e 100644 --- a/osf/models/validators.py +++ b/osf/models/validators.py @@ -8,8 +8,6 @@ from django.utils.deconstruct import deconstructible from rest_framework import exceptions -from website.notifications.constants import NOTIFICATION_TYPES - from osf.utils.registrations import FILE_VIEW_URL_REGEX from osf.utils.sanitize import strip_html from osf.exceptions import ValidationError, ValidationValueError, reraise_django_validation_errors, BlockedEmailError @@ -54,7 +52,7 @@ def string_required(value): def validate_subscription_type(value): - if value not in NOTIFICATION_TYPES: + if value not in ['email_transactional', 'email_digest', 'none']: raise ValidationValueError diff --git a/osf/static/admin/notification_subscription.js b/osf/static/admin/notification_subscription.js new file mode 100644 index 00000000000..c4f9ac87a2e --- /dev/null +++ b/osf/static/admin/notification_subscription.js @@ -0,0 +1,34 @@ +document.addEventListener('DOMContentLoaded', function () { + const typeSelect = document.querySelector('#id_notification_type'); + const freqSelect = document.querySelector('#id_message_frequency'); + + if (!typeSelect || !freqSelect) return; + + function updateIntervals(typeId) { + fetch(`/admin/osf/notificationsubscription/get-intervals/${typeId}/`) + .then(response => response.json()) + .then(data => { + // Clear current options + freqSelect.innerHTML = ''; + + // Add new ones + data.intervals.forEach(choice => { + const option = document.createElement('option'); + option.value = choice; + option.textContent = choice; + freqSelect.appendChild(option); + }); + }); + } + + typeSelect.addEventListener('change', function () { + if (this.value) { + updateIntervals(this.value); + } + }); + + // Auto-load if there's an initial value + if (typeSelect.value) { + updateIntervals(typeSelect.value); + } +}); \ No newline at end of file diff --git a/osf/utils/machines.py b/osf/utils/machines.py index 04713b3cb26..5f48089902c 100644 --- a/osf/utils/machines.py +++ b/osf/utils/machines.py @@ -6,6 +6,7 @@ from framework.auth import Auth from osf.exceptions import InvalidTransitionError +from osf.models.notification_type import NotificationType from osf.models.preprintlog import PreprintLog from osf.models.action import ReviewAction, NodeRequestAction, PreprintRequestAction from osf.utils import permissions @@ -21,7 +22,6 @@ COLLECTION_SUBMISSION_TRANSITIONS, NodeRequestTypes ) -from website.mails import mails from website.reviews import signals as reviews_signals from website.settings import DOMAIN, OSF_SUPPORT_EMAIL, OSF_CONTACT_EMAIL @@ -166,36 +166,55 @@ def notify_edit_comment(self, ev): def notify_withdraw(self, ev): context = self.get_context() - context['ever_public'] = self.machineable.ever_public + context['force_withdrawal'] = False + try: - preprint_request_action = PreprintRequestAction.objects.get(target__target__id=self.machineable.id, - from_state='pending', - to_state='accepted', - trigger='accept') - context['requester'] = preprint_request_action.target.creator + preprint_request_action = PreprintRequestAction.objects.get( + target__target__id=self.machineable.id, + from_state='pending', + to_state='accepted', + trigger='accept' + ) + requester = preprint_request_action.target.creator + comment = preprint_request_action.comment except PreprintRequestAction.DoesNotExist: # If there is no preprint request action, it means the withdrawal is directly initiated by admin/moderator context['force_withdrawal'] = True + requester = self.machineable.creator + comment = None + + context['requester_fullname'] = requester.fullname + context['ever_public'] = self.machineable.ever_public + context['reviewable_withdrawal_justification'] = self.machineable.withdrawal_justification for contributor in self.machineable.contributors.all(): - context['contributor'] = contributor - if context.get('requester', None): - context['is_requester'] = context['requester'].username == contributor.username - mails.send_mail( - contributor.username, - mails.WITHDRAWAL_REQUEST_GRANTED, - document_type=self.machineable.provider.preprint_word, - **context + context['contributor_fullname'] = contributor.fullname + if context.get('requester_fullname', None): + context['is_requester'] = requester == contributor + + NotificationType.Type.PREPRINT_REQUEST_WITHDRAWAL_APPROVED.instance.emit( + user=contributor, + subscribed_object=self.machineable, + event_context={ + 'document_type': self.machineable.provider.preprint_word, + 'reviewable_provider_name': self.machineable.provider.name, + 'comment': comment, + 'notify_comment': not self.machineable.provider.reviews_comments_private, + **context + } ) def get_context(self): return { 'domain': DOMAIN, - 'reviewable': self.machineable, + 'reviewable_title': self.machineable.title, + 'reviewable_absolute_url': self.machineable.absolute_url, + 'reviewable_provider__id': self.machineable.provider._id, 'workflow': self.machineable.provider.reviews_workflow, 'provider_url': self.machineable.provider.domain or f'{DOMAIN}preprints/{self.machineable.provider._id}', 'provider_contact_email': self.machineable.provider.email_contact or OSF_CONTACT_EMAIL, 'provider_support_email': self.machineable.provider.email_support or OSF_SUPPORT_EMAIL, + 'osf_contact_email': OSF_SUPPORT_EMAIL, } @@ -214,13 +233,18 @@ def save_changes(self, ev): contributor_permissions = ev.kwargs.get('permissions', self.machineable.requested_permissions) make_curator = self.machineable.request_type == NodeRequestTypes.INSTITUTIONAL_REQUEST.value visible = False if make_curator else ev.kwargs.get('visible', True) + if self.machineable.request_type in (NodeRequestTypes.ACCESS.value, NodeRequestTypes.INSTITUTIONAL_REQUEST.value): + notification_type = NotificationType.Type.NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST + else: + notification_type = None + try: self.machineable.target.add_contributor( self.machineable.creator, auth=Auth(ev.kwargs['user']), permissions=contributor_permissions, visible=visible, - send_email=f'{self.machineable.request_type}_request', + notification_type=notification_type, make_curator=make_curator, ) except IntegrityError as e: @@ -238,14 +262,16 @@ def notify_submit(self, ev): context = self.get_context() context['contributors_url'] = f'{self.machineable.target.absolute_url}contributors/' context['project_settings_url'] = f'{self.machineable.target.absolute_url}settings/' + if not self.machineable.request_type == NodeRequestTypes.INSTITUTIONAL_REQUEST.value: for admin in self.machineable.target.get_users_with_perm(permissions.ADMIN): - mails.send_mail( - admin.username, - mails.ACCESS_REQUEST_SUBMITTED, - admin=admin, - osf_contact_email=OSF_CONTACT_EMAIL, - **context + NotificationType.Type.NODE_REQUEST_ACCESS_SUBMITTED.instance.emit( + user=admin, + subscribed_object=self.machineable, + event_context={ + 'user_fullname': admin.fullname, + **context + } ) def notify_resubmit(self, ev): @@ -259,11 +285,11 @@ def notify_accept_reject(self, ev): """ if ev.event.name == DefaultTriggers.REJECT.value: context = self.get_context() - mails.send_mail( - self.machineable.creator.username, - mails.ACCESS_REQUEST_DENIED, - osf_contact_email=OSF_CONTACT_EMAIL, - **context + + NotificationType.Type.NODE_REQUEST_ACCESS_DENIED.instance.emit( + user=self.machineable.creator, + subscribed_object=self.machineable, + event_context=context ) else: # add_contributor sends approval notification email @@ -276,8 +302,11 @@ def notify_edit_comment(self, ev): def get_context(self): return { - 'node': self.machineable.target, - 'requester': self.machineable.creator + 'node_title': self.machineable.target.title, + 'node_id': self.machineable.target._id, + 'node_absolute_url': self.machineable.target.absolute_url, + 'requester_fullname': self.machineable.creator.fullname, + 'requester_absolute_url': self.machineable.creator.absolute_url, } @@ -310,10 +339,13 @@ def notify_submit(self, ev): def notify_accept_reject(self, ev): if ev.event.name == DefaultTriggers.REJECT.value: context = self.get_context() - mails.send_mail( - self.machineable.creator.username, - mails.WITHDRAWAL_REQUEST_DECLINED, - **context + context['comment'] = self.action.comment + context['contributor_fullname'] = self.machineable.creator.fullname + + NotificationType.Type.PREPRINT_REQUEST_WITHDRAWAL_DECLINED.instance.emit( + user=self.machineable.creator, + subscribed_object=self.machineable, + event_context=context ) else: pass @@ -332,10 +364,14 @@ def notify_resubmit(self, ev): def get_context(self): return { - 'reviewable': self.machineable.target, - 'requester': self.machineable.creator, + 'reviewable_title': self.machineable.target.title, + 'reviewable_absolute_url': self.machineable.target.absolute_url, + 'reviewable_provider__id': self.machineable.target.provider._id, + 'reviewable_provider_name': self.machineable.target.provider.name, + 'requester_fullname': self.machineable.creator.fullname, 'is_request_email': True, - 'document_type': self.machineable.target.provider.preprint_word + 'document_type': self.machineable.target.provider.preprint_word, + 'notify_comment': not self.machineable.target.provider.reviews_comments_private, } diff --git a/osf/utils/notifications.py b/osf/utils/notifications.py index 92ea38fcf70..fc4ef6ff277 100644 --- a/osf/utils/notifications.py +++ b/osf/utils/notifications.py @@ -1,5 +1,6 @@ from django.utils import timezone -from website.mails import mails + +from osf.models.notification_type import NotificationType from website.reviews import signals as reviews_signals from website.settings import DOMAIN, OSF_SUPPORT_EMAIL, OSF_CONTACT_EMAIL from osf.utils.workflows import RegistrationModerationTriggers @@ -8,19 +9,27 @@ def get_email_template_context(resource): is_preprint = resource.provider.type == 'osf.preprintprovider' url_segment = 'preprints' if is_preprint else 'registries' document_type = resource.provider.preprint_word if is_preprint else 'registration' + from website.profile.utils import get_profile_image_url base_context = { 'domain': DOMAIN, - 'reviewable': resource, + 'reviewable_title': resource.title, + 'reviewable_branched_from_node': getattr(resource, 'branched_from_node', None), + 'reviewable_absolute_url': resource.absolute_url, + 'profile_image_url': get_profile_image_url(resource.creator), + 'reviewable_provider_name': resource.provider.name, 'workflow': resource.provider.reviews_workflow, 'provider_url': resource.provider.domain or f'{DOMAIN}{url_segment}/{resource.provider._id}', + 'provider_type': resource.provider.type, + 'provider_name': resource.provider.name, 'provider_contact_email': resource.provider.email_contact or OSF_CONTACT_EMAIL, 'provider_support_email': resource.provider.email_support or OSF_SUPPORT_EMAIL, - 'document_type': document_type + 'document_type': document_type, + 'notify_comment': not resource.provider.reviews_comments_private } if document_type == 'registration': - base_context['draft_registration'] = resource.draft_registration.get() + base_context['draft_registration_absolute_url'] = resource.draft_registration.get().absolute_url if document_type == 'registration' and resource.provider.brand: brand = resource.provider.brand base_context['logo_url'] = brand.hero_logo_image @@ -31,46 +40,59 @@ def get_email_template_context(resource): def notify_submit(resource, user, *args, **kwargs): context = get_email_template_context(resource) - context['referrer'] = user recipients = list(resource.contributors) + context['requester_fullname'] = user.fullname + context['referrer_fullname'] = user.fullname + context['user_fullname'] = user.fullname reviews_signals.reviews_email_submit.send( context=context, - recipients=recipients + recipients=recipients, + resource=resource, + notification_type=NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION ) reviews_signals.reviews_email_submit_moderators_notifications.send( timestamp=timezone.now(), - context=context + context=context, + resource=resource, + ) def notify_resubmit(resource, user, *args, **kwargs): context = get_email_template_context(resource) - context['referrer'] = user + context['referrer_fullname'] = user.fullname + context['requester_fullname'] = user.fullname + context['user_fullname'] = user.fullname + context['resubmission'] = True recipients = list(resource.contributors) reviews_signals.reviews_email_submit.send( recipients=recipients, context=context, - template=mails.REVIEWS_RESUBMISSION_CONFIRMATION, + notification_type=NotificationType.Type.PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION, + resource=resource, ) reviews_signals.reviews_email_submit_moderators_notifications.send( timestamp=timezone.now(), - context=context + context=context, + resource=resource, ) def notify_accept_reject(resource, user, action, states, *args, **kwargs): context = get_email_template_context(resource) + context['user_fullname'] = user.fullname - context['notify_comment'] = not resource.provider.reviews_comments_private and action.comment context['comment'] = action.comment - context['requester'] = action.creator + context['requester_fullname'] = action.creator.fullname context['is_rejected'] = action.to_state == states.REJECTED.db_name context['was_pending'] = action.from_state == states.PENDING.db_name + context['notify_comment'] = not resource.provider.reviews_comments_private + reviews_signals.reviews_email.send( creator=user, context=context, - template='reviews_submission_status', + template=NotificationType.Type.REVIEWS_SUBMISSION_STATUS, action=action ) @@ -89,26 +111,33 @@ def notify_edit_comment(resource, user, action, *args, **kwargs): def notify_reject_withdraw_request(resource, action, *args, **kwargs): context = get_email_template_context(resource) - context['requester'] = action.creator + context['requester_fullname'] = action.creator.fullname + context['referrer_fullname'] = action.creator.fullname + context['force_withdrawal'] = False + context['notify_comment'] = not resource.provider.reviews_comments_private + context['reviewable_withdrawal_justification'] = resource.withdrawal_justification for contributor in resource.contributors.all(): - context['contributor'] = contributor - context['requester'] = action.creator + context['user_fullname'] = contributor.fullname + context['contributor_fullname'] = contributor.fullname context['is_requester'] = action.creator == contributor - - mails.send_mail( - contributor.username, - mails.WITHDRAWAL_REQUEST_DECLINED, - **context + context['comment'] = action.comment + NotificationType.Type.NODE_WITHDRAWAl_REQUEST_APPROVED.instance.emit( + user=contributor, + event_context={ + 'is_requester': contributor == action.creator, + 'ever_public': getattr(resource, 'ever_public', resource.is_public), + **context + }, ) - def notify_moderator_registration_requests_withdrawal(resource, user, *args, **kwargs): context = get_email_template_context(resource) - context['referrer'] = user reviews_signals.reviews_withdraw_requests_notification_moderators.send( timestamp=timezone.now(), - context=context + context=context, + resource=resource, + user=user ) @@ -116,15 +145,19 @@ def notify_withdraw_registration(resource, action, *args, **kwargs): context = get_email_template_context(resource) context['force_withdrawal'] = action.trigger == RegistrationModerationTriggers.FORCE_WITHDRAW.db_name - context['requester'] = resource.retraction.initiated_by + context['requester_fullname'] = resource.retraction.initiated_by.fullname context['comment'] = action.comment - context['notify_comment'] = not resource.provider.reviews_comments_private and action.comment + context['force_withdrawal'] = False + context['notify_comment'] = not resource.provider.reviews_comments_private + context['reviewable_withdrawal_justification'] = resource.withdrawal_justification + context['ever_public'] = getattr(resource, 'ever_public', resource.is_public) for contributor in resource.contributors.all(): - context['contributor'] = contributor - context['is_requester'] = context['requester'] == contributor - mails.send_mail( - contributor.username, - mails.WITHDRAWAL_REQUEST_GRANTED, - **context + context['contributor_fullname'] = contributor.fullname + context['user_fullname'] = contributor.fullname + context['is_requester'] = resource.retraction.initiated_by == contributor + + NotificationType.Type.NODE_WITHDRAWAl_REQUEST_APPROVED.instance.emit( + user=contributor, + event_context=context ) diff --git a/osf_tests/conftest.py b/osf_tests/conftest.py index af71872cb41..a0fafde4231 100644 --- a/osf_tests/conftest.py +++ b/osf_tests/conftest.py @@ -4,8 +4,6 @@ from framework.django.handlers import handlers as django_handlers from framework.flask import rm_handlers from website.app import init_app -from website.project.signals import contributor_added -from website.project.views.contributor import notify_added_contributor # NOTE: autouse so that ADDONS_REQUESTED gets set on website.settings @@ -37,13 +35,3 @@ def request_context(app): context.push() yield context context.pop() - -DISCONNECTED_SIGNALS = { - # disconnect notify_add_contributor so that add_contributor does not send "fake" emails in tests - contributor_added: [notify_added_contributor] -} -@pytest.fixture(autouse=True) -def disconnected_signals(): - for signal in DISCONNECTED_SIGNALS: - for receiver in DISCONNECTED_SIGNALS[signal]: - signal.disconnect(receiver) diff --git a/osf_tests/embargoes/test_embargoes.py b/osf_tests/embargoes/test_embargoes.py index 468f5b03aaf..ba79c0fe927 100644 --- a/osf_tests/embargoes/test_embargoes.py +++ b/osf_tests/embargoes/test_embargoes.py @@ -12,6 +12,7 @@ from scripts.embargo_registrations import main as approve_embargos from django.utils import timezone from osf.utils.workflows import RegistrationModerationStates +from tests.utils import capture_notifications @pytest.mark.django_db @@ -35,8 +36,8 @@ def test_request_early_termination_too_late(self, registration, user): This is for an edge case test for where embargos are frozen and never expire when the user requests they be terminated with embargo with less then 48 hours before it would expire anyway. """ - - registration.request_embargo_termination(user) + with capture_notifications(): + registration.request_embargo_termination(user) mock_now = timezone.now() + datetime.timedelta(days=6) with mock.patch.object(timezone, 'now', return_value=mock_now): approve_embargos(dry_run=False) diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 1310c9aed63..b472856e8a4 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -6,7 +6,7 @@ from unittest import mock from factory import SubFactory -from factory.fuzzy import FuzzyDateTime, FuzzyAttribute, FuzzyChoice +from factory.fuzzy import FuzzyDateTime, FuzzyChoice from unittest.mock import patch, Mock import pytz @@ -20,7 +20,6 @@ from django.db.utils import IntegrityError from faker import Factory, Faker from waffle.models import Flag, Sample, Switch -from website.notifications.constants import NOTIFICATION_TYPES from osf.utils import permissions from website.archiver import ARCHIVER_SUCCESS from website.settings import FAKE_EMAIL_NAME, FAKE_EMAIL_DOMAIN @@ -573,6 +572,7 @@ def _create(cls, *args, **kwargs): schema=registration_schema, data=registration_metadata, provider=provider, + notification_type=False ) if title: draft.title = title @@ -797,7 +797,12 @@ def _create(cls, target_class, *args, **kwargs): instance.set_subjects(subjects, auth=auth) if license_details: instance.set_preprint_license(license_details, auth=auth) - instance.set_published(is_published, auth=auth) + from tests.utils import capture_notifications + if is_published: + with capture_notifications(): + instance.set_published(is_published, auth=auth) + else: + instance.set_published(is_published, auth=auth) create_task_patcher = mock.patch('website.identifiers.utils.request_identifiers') mock_create_identifier = create_task_patcher.start() if is_published and set_doi: @@ -1040,9 +1045,20 @@ def handle_callback(self, response): } +class NotificationSubscriptionLegacyFactory(DjangoModelFactory): + 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(): @@ -1053,18 +1069,6 @@ def make_node_lineage(): return [node1._id, node2._id, node3._id, node4._id] - -class NotificationDigestFactory(DjangoModelFactory): - timestamp = FuzzyDateTime(datetime.datetime(1970, 1, 1, tzinfo=pytz.UTC)) - node_lineage = FuzzyAttribute(fuzzer=make_node_lineage) - user = factory.SubFactory(UserFactory) - send_type = FuzzyChoice(choices=NOTIFICATION_TYPES.keys()) - message = fake.text(max_nb_chars=2048) - event = fake.text(max_nb_chars=50) - class Meta: - model = models.NotificationDigest - - class ConferenceFactory(DjangoModelFactory): class Meta: model = models.Conference @@ -1319,7 +1323,10 @@ def _create(cls, *args, **kwargs): ).get() previous_schema_response.approvals_state_machine.set_state(ApprovalStates.APPROVED) previous_schema_response.save() - return SchemaResponse.create_from_previous_response(initiator, previous_schema_response, justification) + from tests.utils import capture_notifications + + with capture_notifications(): + return SchemaResponse.create_from_previous_response(initiator, previous_schema_response, justification) class SchemaResponseActionFactory(DjangoModelFactory): diff --git a/osf_tests/management_commands/test_approve_pending_schema_responses.py b/osf_tests/management_commands/test_approve_pending_schema_responses.py index cc50f6e13d0..eb0dee91328 100644 --- a/osf_tests/management_commands/test_approve_pending_schema_responses.py +++ b/osf_tests/management_commands/test_approve_pending_schema_responses.py @@ -7,6 +7,7 @@ from osf.models import SchemaResponse from osf.utils.workflows import ApprovalStates from osf_tests.factories import RegistrationFactory +from tests.utils import capture_notifications from website.settings import REGISTRATION_UPDATE_APPROVAL_TIME @@ -23,10 +24,10 @@ def control_response(self): initial_response = reg.schema_responses.last() initial_response.state = ApprovalStates.APPROVED initial_response.save() - - revision = SchemaResponse.create_from_previous_response( - previous_response=initial_response, initiator=reg.creator - ) + with capture_notifications(): + revision = SchemaResponse.create_from_previous_response( + previous_response=initial_response, initiator=reg.creator + ) revision.state = ApprovalStates.UNAPPROVED revision.submitted_timestamp = AUTO_APPROVE_TIMESTAMP revision.save() @@ -38,22 +39,23 @@ def test_response(self): initial_response = reg.schema_responses.last() initial_response.state = ApprovalStates.APPROVED initial_response.save() - - return SchemaResponse.create_from_previous_response( - previous_response=initial_response, initiator=reg.creator - ) + with capture_notifications(): + return SchemaResponse.create_from_previous_response( + previous_response=initial_response, initiator=reg.creator + ) @pytest.mark.parametrize( 'is_moderated, expected_state', [(False, ApprovalStates.APPROVED), (True, ApprovalStates.PENDING_MODERATION)] ) def test_auto_approval(self, control_response, is_moderated, expected_state): - with mock.patch( - 'osf.models.schema_response.SchemaResponse.is_moderated', - new_callaoble=mock.PropertyMock - ) as mock_is_moderated: - mock_is_moderated.return_value = is_moderated - count = approve_pending_schema_responses() + with capture_notifications(): + with mock.patch( + 'osf.models.schema_response.SchemaResponse.is_moderated', + new_callaoble=mock.PropertyMock + ) as mock_is_moderated: + mock_is_moderated.return_value = is_moderated + count = approve_pending_schema_responses() assert count == 1 @@ -65,8 +67,8 @@ def test_auto_approval_with_multiple_pending_schema_responses( test_response.state = ApprovalStates.UNAPPROVED test_response.submitted_timestamp = AUTO_APPROVE_TIMESTAMP test_response.save() - - count = approve_pending_schema_responses() + with capture_notifications(): + count = approve_pending_schema_responses() assert count == 2 control_response.refresh_from_db() @@ -80,8 +82,8 @@ def test_auto_approval_only_approves_unapproved_schema_responses( test_response.state = revision_state test_response.submitted_timestamp = AUTO_APPROVE_TIMESTAMP test_response.save() - - count = approve_pending_schema_responses() + with capture_notifications(): + count = approve_pending_schema_responses() assert count == 1 control_response.refresh_from_db() @@ -95,7 +97,8 @@ def test_auto_approval_only_approves_schema_responses_older_than_threshold( test_response.submitted_timestamp = timezone.now() test_response.save() - count = approve_pending_schema_responses() + with capture_notifications(): + count = approve_pending_schema_responses() assert count == 1 control_response.refresh_from_db() @@ -110,7 +113,8 @@ def test_auto_approval_does_not_pick_up_initial_responses( test_response.submitted_timestamp = timezone.now() test_response.save() - count = approve_pending_schema_responses() + with capture_notifications(): + count = approve_pending_schema_responses() assert count == 1 control_response.refresh_from_db() diff --git a/osf_tests/management_commands/test_check_crossref_dois.py b/osf_tests/management_commands/test_check_crossref_dois.py deleted file mode 100644 index 993c7e6731e..00000000000 --- a/osf_tests/management_commands/test_check_crossref_dois.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -from unittest import mock -import pytest -import json -from datetime import timedelta -import responses -HERE = os.path.dirname(os.path.abspath(__file__)) - - -from osf_tests.factories import PreprintFactory -from website import settings - -from osf.management.commands.check_crossref_dois import check_crossref_dois, report_stuck_dois - - -@pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') -class TestCheckCrossrefDOIs: - - @pytest.fixture() - def preprint(self): - return PreprintFactory() - - @pytest.fixture() - def stuck_preprint(self): - preprint = PreprintFactory(set_doi=False, set_guid='guid0') - preprint.date_published = preprint.date_published - timedelta(days=settings.DAYS_CROSSREF_DOIS_MUST_BE_STUCK_BEFORE_EMAIL + 1) - # match guid to the fixture crossref_works_response.json - guid = preprint.guids.first() - provider = preprint.provider - provider.doi_prefix = '10.31236' - provider.save() - guid._id = 'guid0' - guid.save() - - preprint.save() - return preprint - - @pytest.fixture() - def crossref_response(self): - with open(os.path.join(HERE, 'fixtures/crossref_works_response.json'), 'rb') as fp: - return json.loads(fp.read()) - - @responses.activate - @mock.patch('osf.models.preprint.update_or_enqueue_on_preprint_updated', mock.Mock()) - def test_check_crossref_dois(self, crossref_response, stuck_preprint, preprint): - doi = settings.DOI_FORMAT.format(prefix=stuck_preprint.provider.doi_prefix, guid=stuck_preprint._id) - responses.add( - responses.Response( - responses.GET, - url=f'{settings.CROSSREF_JSON_API_URL}works?filter=doi:{doi}', - json=crossref_response, - status=200 - ) - ) - - check_crossref_dois(dry_run=False) - - assert preprint.identifiers.count() == 1 - - assert stuck_preprint.identifiers.count() == 1 - assert stuck_preprint.identifiers.first().value == doi - - def test_report_stuck_dois(self, mock_send_grid, stuck_preprint): - report_stuck_dois(dry_run=False) - - mock_send_grid.assert_called() diff --git a/osf_tests/management_commands/test_email_all_users.py b/osf_tests/management_commands/test_email_all_users.py deleted file mode 100644 index 14df656ee52..00000000000 --- a/osf_tests/management_commands/test_email_all_users.py +++ /dev/null @@ -1,65 +0,0 @@ -import pytest - -from django.utils import timezone - -from osf_tests.factories import UserFactory - -from osf.management.commands.email_all_users import email_all_users - -@pytest.mark.usefixtures('mock_send_grid') -class TestEmailAllUsers: - - @pytest.fixture() - def user(self): - return UserFactory(id=1) - - @pytest.fixture() - def user2(self): - return UserFactory(id=2) - - @pytest.fixture() - def superuser(self): - user = UserFactory() - user.is_superuser = True - user.save() - return user - - @pytest.fixture() - def deleted_user(self): - return UserFactory(deleted=timezone.now()) - - @pytest.fixture() - def inactive_user(self): - return UserFactory(is_disabled=True) - - @pytest.fixture() - def unconfirmed_user(self): - return UserFactory(date_confirmed=None) - - @pytest.fixture() - def unregistered_user(self): - return UserFactory(is_registered=False) - - @pytest.mark.django_db - def test_email_all_users_dry(self, mock_send_grid, superuser): - email_all_users('TOU_NOTIF', dry_run=True) - - mock_send_grid.assert_called() - - @pytest.mark.django_db - def test_dont_email_inactive_users( - self, mock_send_grid, deleted_user, inactive_user, unconfirmed_user, unregistered_user): - - email_all_users('TOU_NOTIF') - - mock_send_grid.assert_not_called() - - @pytest.mark.django_db - def test_email_all_users_offset(self, mock_send_grid, user, user2): - email_all_users('TOU_NOTIF', offset=1, start_id=0) - - email_all_users('TOU_NOTIF', offset=1, start_id=1) - - email_all_users('TOU_NOTIF', offset=1, start_id=2) - - assert mock_send_grid.call_count == 2 diff --git a/osf_tests/management_commands/test_force_archive.py b/osf_tests/management_commands/test_force_archive.py index 916b68b2cb8..cdd134a02d3 100644 --- a/osf_tests/management_commands/test_force_archive.py +++ b/osf_tests/management_commands/test_force_archive.py @@ -102,49 +102,6 @@ def test_generic_fallback(self, node, reg): file_obj = get_file_obj_from_log(log, reg) assert file_obj == file - @pytest.mark.django_db - def test_file_multiple_creations_deletions(self, node, reg): - file1 = OsfStorageFile.create(target=node, name='duplicate.txt') - file1.save() - file1.delete() - log1 = NodeLog.objects.create( - node=node, - action='osf_storage_file_removed', - params={'path': '/duplicate.txt'}, - date=timezone.now(), - ) - - file2 = OsfStorageFile.create(target=node, name='duplicate.txt') - file2.save() - file2.delete() - log2 = NodeLog.objects.create( - node=node, - action='osf_storage_file_removed', - params={'path': '/duplicate.txt'}, - date=timezone.now(), - ) - - file3 = OsfStorageFile.create(target=node, name='duplicate.txt') - file3.save() - log3 = NodeLog.objects.create( - node=node, - action='osf_storage_file_added', - params={'urls': {'view': f'/{node._id}/files/osfstorage/{file3._id}/'}}, - date=timezone.now(), - ) - - file_obj1 = get_file_obj_from_log(log1, reg) - assert file_obj1 == file1 - assert isinstance(file_obj1, TrashedFileNode) - - file_obj2 = get_file_obj_from_log(log2, reg) - assert file_obj2 == file2 - assert isinstance(file_obj2, TrashedFileNode) - - file_obj3 = get_file_obj_from_log(log3, reg) - assert file_obj3 == file3 - assert isinstance(file_obj3, OsfStorageFile) - class TestBuildFileTree: 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..ae223337f29 --- /dev/null +++ b/osf_tests/management_commands/test_migrate_notifications.py @@ -0,0 +1,215 @@ +import pytest +from django.contrib.contenttypes.models import ContentType +from django.db import transaction + +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, + populate_notification_types, EVENT_NAME_TO_NOTIFICATION_TYPE +) + +@pytest.mark.django_db +class TestNotificationSubscriptionMigration: + + @pytest.fixture(autouse=True) + def notification_types(self): + return populate_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() + + ALL_PROVIDER_EVENTS = [ + 'new_pending_withdraw_requests', + 'contributor_added_preprint', + 'new_pending_submissions', + 'moderator_added', + 'reviews_submission_confirmation', + 'reviews_resubmission_confirmation', + 'confirm_email_moderation', + ] + + ALL_NODE_EVENTS = [ + 'file_updated', + ] + + ALL_COLLECTION_EVENTS = [ + 'collection_submission_submitted', + 'collection_submission_accepted', + 'collection_submission_rejected', + 'collection_submission_removed_admin', + 'collection_submission_removed_moderator', + 'collection_submission_removed_private', + 'collection_submission_cancel', + ] + + ALL_EVENT_NAMES = ALL_PROVIDER_EVENTS + ALL_NODE_EVENTS + ALL_COLLECTION_EVENTS + + 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 + ) + if hasattr(legacy, 'none'): + legacy.none.add(users['none']) + if hasattr(legacy, 'email_digest'): + legacy.email_digest.add(users['digest']) + if hasattr(legacy, 'email_transactional'): + legacy.email_transactional.add(users['transactional']) + return legacy + + def test_migrate_provider_subscription(self, users, provider, provider2): + self.create_legacy_sub(event_name='new_pending_submissions', users=users, provider=provider) + self.create_legacy_sub(event_name='new_pending_submissions', users=users, provider=provider2) + self.create_legacy_sub(event_name='new_pending_submissions', users=users, provider=RegistrationProvider.get_default()) + migrate_legacy_notification_subscriptions() + subs = NotificationSubscription.objects.filter( + notification_type__name=NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + ) + assert subs.count() == 3 + for obj in [provider, provider2, RegistrationProvider.get_default()]: + content_type = ContentType.objects.get_for_model(obj.__class__) + assert subs.filter(object_id=obj.id, content_type=content_type).exists() + + def test_migrate_node_subscription(self, users, user, node): + self.create_legacy_sub('file_updated', users, user=user, node=node) + migrate_legacy_notification_subscriptions() + nt = NotificationType.objects.get(name=NotificationType.Type.NODE_FILE_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_no_old_types(self, users, user, provider, node): + assert not NotificationSubscription.objects.filter(user=user) + self.create_legacy_sub('comments', users, user=user, node=node) + migrate_legacy_notification_subscriptions() + assert not NotificationSubscription.objects.filter(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=NotificationType.Type.NODE_FILE_UPDATED + ) + + def test_migrate_all_subscription_types(self, users, user, provider, provider2, node): + providers = [provider, provider2] + for event_name in self.ALL_EVENT_NAMES: + if event_name in self.ALL_PROVIDER_EVENTS: + self.create_legacy_sub(event_name=event_name, users=users, user=user, node=node, provider=provider) + self.create_legacy_sub(event_name=event_name, users=users, user=user, node=node, provider=provider2) + else: + self.create_legacy_sub(event_name=event_name, users=users, user=user, node=node) + + # Run migration the first time + migrate_legacy_notification_subscriptions() + subs = NotificationSubscription.objects.all() + # Calculate expected total + expected_total = len(self.ALL_PROVIDER_EVENTS) * len(providers) \ + + len(self.ALL_NODE_EVENTS) \ + + len(self.ALL_COLLECTION_EVENTS) + assert subs.count() >= expected_total + # Run migration again to test deduplication + migrate_legacy_notification_subscriptions() + subs_after_second_run = NotificationSubscription.objects.all() + assert subs_after_second_run.count() == subs.count() + # Verify every notification type is present + for nt_legacy_name in self.ALL_EVENT_NAMES: + nt_name = EVENT_NAME_TO_NOTIFICATION_TYPE[nt_legacy_name].value + nt_objs = NotificationSubscription.objects.filter( + notification_type__name=nt_name + ) + assert nt_objs.exists() + # Verify subscriptions belong to correct objects + for provider in providers: + content_type = ContentType.objects.get_for_model(provider.__class__) + assert NotificationSubscription.objects.filter( + notification_type=NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.instance, + content_type=content_type, + object_id=provider.id + ).exists() + node_ct = ContentType.objects.get_for_model(node.__class__) + assert NotificationSubscription.objects.filter( + notification_type=NotificationType.Type.NODE_FILE_UPDATED.instance, + content_type=node_ct, + object_id=node.id + ).exists() + + def test_migrate_rolls_back_on_runtime_error(self, users, user, node, provider): + user = AuthUserFactory() + self.create_legacy_sub(event_name='collection_submission_submitted', users=users, user=user, node=node, provider=provider) + + def failing_migration(): + with transaction.atomic(): + migrate_legacy_notification_subscriptions() + raise RuntimeError('Simulated failure') + + with pytest.raises(RuntimeError): + failing_migration() + assert NotificationSubscription.objects.filter(user=user).count() == 0 + + def test_migrate_skips_invalid_data(self, users, user, node, provider): + self.create_legacy_sub(event_name='wrong_data', users=users, user=user, node=node, provider=provider) + migrate_legacy_notification_subscriptions() + assert NotificationSubscription.objects.filter(user=user).count() == 0 + + def test_migrate_batch_with_valid_and_invalid(self, users, user, node, provider): + # Valid subscription + self.create_legacy_sub( + event_name='reviews_resubmission_confirmation', + users=users, + user=user, + node=node, + provider=provider, + ) + # Invalid subscription + self.create_legacy_sub( + event_name='wrong_data', + users=users, + user=user, + node=node, + provider=provider, + ) + migrate_legacy_notification_subscriptions() + assert NotificationSubscription.objects.filter(user=user).count() == 1 + migrated = NotificationSubscription.objects.filter(user=user).first() + assert migrated.notification_type.name == NotificationType.Type.PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION.value diff --git a/osf_tests/management_commands/test_migrate_preprint_affiliations.py b/osf_tests/management_commands/test_migrate_preprint_affiliations.py deleted file mode 100644 index 8c80737b3dd..00000000000 --- a/osf_tests/management_commands/test_migrate_preprint_affiliations.py +++ /dev/null @@ -1,151 +0,0 @@ -import pytest -from datetime import timedelta -from osf.management.commands.migrate_preprint_affiliation import AFFILIATION_TARGET_DATE, assign_affiliations_to_preprints -from osf_tests.factories import ( - PreprintFactory, - InstitutionFactory, - AuthUserFactory, -) - - -@pytest.mark.django_db -class TestAssignAffiliationsToPreprints: - - @pytest.fixture() - def institution(self): - return InstitutionFactory() - - @pytest.fixture() - def user_with_affiliation(self, institution): - user = AuthUserFactory() - user.add_or_update_affiliated_institution(institution) - user.save() - return user - - @pytest.fixture() - def user_without_affiliation(self): - return AuthUserFactory() - - @pytest.fixture() - def preprint_with_affiliated_contributor(self, user_with_affiliation): - preprint = PreprintFactory() - preprint.add_contributor( - user_with_affiliation, - permissions='admin', - visible=True - ) - preprint.created = AFFILIATION_TARGET_DATE - timedelta(days=1) - preprint.save() - return preprint - - @pytest.fixture() - def preprint_with_non_affiliated_contributor(self, user_without_affiliation): - preprint = PreprintFactory() - preprint.add_contributor( - user_without_affiliation, - permissions='admin', - visible=True - ) - preprint.created = AFFILIATION_TARGET_DATE - timedelta(days=1) - preprint.save() - return preprint - - @pytest.fixture() - def preprint_past_target_date_with_affiliated_contributor(self, user_with_affiliation): - preprint = PreprintFactory() - preprint.add_contributor( - user_with_affiliation, - permissions='admin', - visible=True - ) - preprint.created = AFFILIATION_TARGET_DATE + timedelta(days=1) - preprint.save() - return preprint - - @pytest.mark.parametrize('dry_run', [True, False]) - def test_assign_affiliations_with_affiliated_contributor(self, preprint_with_affiliated_contributor, institution, dry_run): - preprint = preprint_with_affiliated_contributor - preprint.affiliated_institutions.clear() - preprint.save() - - assign_affiliations_to_preprints(dry_run=dry_run) - - if dry_run: - assert not preprint.affiliated_institutions.exists() - else: - assert institution in preprint.affiliated_institutions.all() - - @pytest.mark.parametrize('dry_run', [True, False]) - def test_no_affiliations_for_non_affiliated_contributor(self, preprint_with_non_affiliated_contributor, dry_run): - preprint = preprint_with_non_affiliated_contributor - preprint.affiliated_institutions.clear() - preprint.save() - - assign_affiliations_to_preprints(dry_run=dry_run) - - assert not preprint.affiliated_institutions.exists() - - @pytest.mark.parametrize('dry_run', [True, False]) - def test_exclude_contributor_by_guid(self, preprint_with_affiliated_contributor, user_with_affiliation, institution, dry_run): - preprint = preprint_with_affiliated_contributor - preprint.affiliated_institutions.clear() - preprint.save() - - assert user_with_affiliation.get_affiliated_institutions() - assert user_with_affiliation in preprint.contributors.all() - exclude_guids = {user._id for user in preprint.contributors.all()} - - assign_affiliations_to_preprints(exclude_guids=exclude_guids, dry_run=dry_run) - - assert not preprint.affiliated_institutions.exists() - - @pytest.mark.parametrize('dry_run', [True, False]) - def test_affiliations_from_multiple_contributors(self, institution, dry_run): - institution_not_include = InstitutionFactory() - read_contrib = AuthUserFactory() - read_contrib.add_or_update_affiliated_institution(institution_not_include) - read_contrib.save() - - write_contrib = AuthUserFactory() - write_contrib.add_or_update_affiliated_institution(institution) - write_contrib.save() - - admin_contrib = AuthUserFactory() - institution2 = InstitutionFactory() - admin_contrib.add_or_update_affiliated_institution(institution2) - admin_contrib.save() - - preprint = PreprintFactory() - preprint.affiliated_institutions.clear() - preprint.created = AFFILIATION_TARGET_DATE - timedelta(days=1) - preprint.add_contributor(read_contrib, permissions='read', visible=True) - preprint.add_contributor(write_contrib, permissions='write', visible=True) - preprint.add_contributor(admin_contrib, permissions='admin', visible=True) - preprint.save() - - assign_affiliations_to_preprints(dry_run=dry_run) - - if dry_run: - assert not preprint.affiliated_institutions.exists() - else: - affiliations = set(preprint.affiliated_institutions.all()) - assert affiliations == {institution, institution2} - assert institution_not_include not in affiliations - - @pytest.mark.parametrize('dry_run', [True, False]) - def test_exclude_recent_preprints(self, preprint_past_target_date_with_affiliated_contributor, preprint_with_affiliated_contributor, institution, dry_run): - new_preprint = preprint_past_target_date_with_affiliated_contributor - new_preprint.affiliated_institutions.clear() - new_preprint.save() - - old_preprint = preprint_with_affiliated_contributor - old_preprint.affiliated_institutions.clear() - old_preprint.save() - - assign_affiliations_to_preprints(dry_run=dry_run) - - assert not new_preprint.affiliated_institutions.exists() - if dry_run: - assert not old_preprint.affiliated_institutions.exists() - else: - assert institution in old_preprint.affiliated_institutions.all() diff --git a/osf_tests/management_commands/test_move_egap_regs_to_provider.py b/osf_tests/management_commands/test_move_egap_regs_to_provider.py deleted file mode 100644 index 4e1ac7291aa..00000000000 --- a/osf_tests/management_commands/test_move_egap_regs_to_provider.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest - -from osf_tests.factories import ( - RegistrationFactory, - RegistrationProviderFactory -) - -from osf.models import ( - RegistrationSchema, - RegistrationProvider -) - -from osf.management.commands.move_egap_regs_to_provider import ( - main as move_egap_regs -) - - -@pytest.mark.django_db -class TestEGAPMoveToProvider: - - @pytest.fixture() - def egap_provider(self): - return RegistrationProviderFactory(_id='egap') - - @pytest.fixture() - def non_egap_provider(self): - return RegistrationProvider.get_default() - - @pytest.fixture() - def egap_reg(self): - egap_schema = RegistrationSchema.objects.filter( - name='EGAP Registration' - ).order_by( - '-schema_version' - )[0] - cos = RegistrationProvider.get_default() - return RegistrationFactory(schema=egap_schema, provider=cos) - - @pytest.fixture() - def egap_non_reg(self, non_egap_provider): - return RegistrationFactory(provider=non_egap_provider) - - def test_move_to_provider(self, egap_provider, egap_reg, non_egap_provider, egap_non_reg): - assert egap_reg.provider != egap_provider - assert egap_non_reg.provider != egap_provider - - move_egap_regs(dry_run=False) - - egap_reg.refresh_from_db() - assert egap_reg.provider == egap_provider - assert egap_non_reg.provider != egap_provider diff --git a/osf_tests/management_commands/test_populate_initial_schema_responses.py b/osf_tests/management_commands/test_populate_initial_schema_responses.py deleted file mode 100644 index 18949c09b33..00000000000 --- a/osf_tests/management_commands/test_populate_initial_schema_responses.py +++ /dev/null @@ -1,130 +0,0 @@ -import pytest - -from osf.management.commands.populate_initial_schema_responses import populate_initial_schema_responses -from osf.models import SchemaResponse, SchemaResponseBlock -from osf.utils.workflows import ApprovalStates, RegistrationModerationStates as RegStates -from osf_tests.factories import ProjectFactory, RegistrationFactory -from osf_tests.utils import get_default_test_schema - -DEFAULT_RESPONSES = { - 'q1': 'An answer', 'q2': 'Another answer', 'q3': 'A', 'q4': ['E'], 'q5': '', 'q6': [], -} - -@pytest.fixture -def control_registration(): - return RegistrationFactory() - - -@pytest.fixture -def test_registration(): - registration = RegistrationFactory(schema=get_default_test_schema()) - registration.schema_responses.clear() - registration.registration_responses = dict(DEFAULT_RESPONSES) - registration.save() - return registration - - -@pytest.fixture -def nested_registration(test_registration): - registration = RegistrationFactory( - project=ProjectFactory(parent=test_registration.registered_from), - parent=test_registration - ) - registration.schema_responses.clear() - return registration - - -@pytest.mark.django_db -class TestPopulateInitialSchemaResponses: - - def test_schema_response_created(self, test_registration): - assert not test_registration.schema_responses.exists() - - count = populate_initial_schema_responses() - assert count == 1 - - assert test_registration.schema_responses.count() == 1 - - schema_response = test_registration.schema_responses.get() - assert schema_response.schema == test_registration.registration_schema - assert schema_response.all_responses == test_registration.registration_responses - - @pytest.mark.parametrize( - 'registration_state, schema_response_state', - [ - (RegStates.INITIAL, ApprovalStates.UNAPPROVED), - (RegStates.PENDING, ApprovalStates.PENDING_MODERATION), - (RegStates.ACCEPTED, ApprovalStates.APPROVED), - (RegStates.EMBARGO, ApprovalStates.APPROVED), - (RegStates.PENDING_EMBARGO_TERMINATION, ApprovalStates.APPROVED), - (RegStates.PENDING_WITHDRAW_REQUEST, ApprovalStates.APPROVED), - (RegStates.PENDING_WITHDRAW, ApprovalStates.APPROVED), - (RegStates.WITHDRAWN, ApprovalStates.APPROVED), - (RegStates.REVERTED, ApprovalStates.UNAPPROVED), - (RegStates.REJECTED, ApprovalStates.PENDING_MODERATION), - ] - ) - def test_schema_response_state( - self, test_registration, registration_state, schema_response_state): - test_registration.moderation_state = registration_state.db_name - test_registration.save() - - populate_initial_schema_responses() - - schema_response = test_registration.schema_responses.get() - assert schema_response.state == schema_response_state - - def test_errors_from_invalid_keys_are_ignored(self, test_registration): - test_registration.registration_responses.update({'invalid_key': 'lolol'}) - test_registration.save() - - populate_initial_schema_responses() - - schema_response = test_registration.schema_responses.get() - assert schema_response.all_responses == DEFAULT_RESPONSES - - def test_populate_responses_is_atomic_per_registration(self, test_registration): - invalid_registration = RegistrationFactory() - invalid_registration.schema_responses.clear() - invalid_registration.registered_schema.clear() - - count = populate_initial_schema_responses() - assert count == 1 - - assert test_registration.schema_responses.exists() - assert not invalid_registration.schema_responses.exists() - - def test_dry_run(self, test_registration): - # donfirm that the delete works even if the schema_response isn't IN_PROGRESS - test_registration.moderation_state = RegStates.ACCEPTED.db_name - test_registration.save() - with pytest.raises(RuntimeError): - populate_initial_schema_responses(dry_run=True) - - assert not test_registration.schema_responses.exists() - assert not SchemaResponse.objects.exists() - assert not SchemaResponseBlock.objects.exists() - - def test_batch_size(self): - for _ in range(5): - r = RegistrationFactory() - r.schema_responses.clear() - assert not SchemaResponse.objects.exists() - - count = populate_initial_schema_responses(batch_size=3) - assert count == 3 - - assert SchemaResponse.objects.count() == 3 - - def test_schema_response_not_created_for_registration_with_response(self, control_registration): - control_registration_response = control_registration.schema_responses.get() - - count = populate_initial_schema_responses() - assert count == 0 - - assert control_registration.schema_responses.get() == control_registration_response - - def test_schema_response_not_created_for_nested_registration(self, nested_registration): - count = populate_initial_schema_responses() - assert count == 1 # parent registration - assert not nested_registration.schema_responses.exists() diff --git a/osf_tests/management_commands/test_withdraw_all_preprints_from_provider.py b/osf_tests/management_commands/test_withdraw_all_preprints_from_provider.py index 59ea95103ea..6e0bdbf770d 100644 --- a/osf_tests/management_commands/test_withdraw_all_preprints_from_provider.py +++ b/osf_tests/management_commands/test_withdraw_all_preprints_from_provider.py @@ -2,6 +2,8 @@ from osf.management.commands.withdraw_all_preprints_from_provider import withdraw_all_preprints from osf_tests.factories import PreprintProviderFactory, PreprintFactory, AuthUserFactory +from tests.utils import capture_notifications + @pytest.mark.django_db class TestWithdrawAllPreprint: @@ -23,7 +25,8 @@ def withdrawing_user(self): return AuthUserFactory() def test_withdraw_all_preprints(self, preprint_provider, provider_preprint, withdrawing_user): - withdraw_all_preprints(preprint_provider._id, 10, withdrawing_user._id, 'test_comment') + with capture_notifications(): + withdraw_all_preprints(preprint_provider._id, 10, withdrawing_user._id, 'test_comment') provider_preprint.reload() assert provider_preprint.is_retracted diff --git a/osf_tests/metadata/test_osf_gathering.py b/osf_tests/metadata/test_osf_gathering.py index 33be346e2df..44c383be4c8 100644 --- a/osf_tests/metadata/test_osf_gathering.py +++ b/osf_tests/metadata/test_osf_gathering.py @@ -8,6 +8,7 @@ from api_tests.utils import create_test_file from framework.auth import Auth +from osf.management.commands.populate_notification_types import populate_notification_types from osf.metadata import osf_gathering from osf.metadata.rdfutils import ( FOAF, @@ -28,6 +29,7 @@ from osf.metrics.utils import YearMonth from osf.utils import permissions, workflows from osf_tests import factories +from tests.utils import capture_notifications from website import settings as website_settings from website.project import new_bookmark_collection from osf_tests.metadata._utils import assert_triples @@ -36,6 +38,8 @@ class TestOsfGathering(TestCase): @classmethod def setUpTestData(cls): + + populate_notification_types() # users: cls.user__admin = factories.UserFactory() cls.user__readwrite = factories.UserFactory( @@ -559,8 +563,9 @@ def test_gather_affiliated_institutions(self): institution = factories.InstitutionFactory() institution_iri = URIRef(institution.ror_uri) self.user__admin.add_or_update_affiliated_institution(institution) - self.project.add_affiliated_institution(institution, self.user__admin) - self.preprint.add_affiliated_institution(institution, self.user__admin) + with capture_notifications(): + self.project.add_affiliated_institution(institution, self.user__admin) + self.preprint.add_affiliated_institution(institution, self.user__admin) assert_triples(osf_gathering.gather_affiliated_institutions(self.projectfocus), { (self.projectfocus.iri, OSF.affiliation, institution_iri), (institution_iri, RDF.type, DCTERMS.Agent), @@ -690,11 +695,12 @@ def test_gather_collection_membership(self): reviews_workflow='post-moderation', ) _collection = factories.CollectionFactory(provider=_collection_provider) - osfdb.CollectionSubmission.objects.create( - guid=self.project.guids.first(), - collection=_collection, - creator=self.project.creator, - ) + with capture_notifications(): + osfdb.CollectionSubmission.objects.create( + guid=self.project.guids.first(), + collection=_collection, + creator=self.project.creator, + ) _collection_ref = rdflib.URIRef( f'{website_settings.DOMAIN}collections/{_collection_provider._id}', ) diff --git a/osf_tests/metrics/reporters/test_institutional_users_reporter.py b/osf_tests/metrics/reporters/test_institutional_users_reporter.py index 275fcb1e8a1..e399d848396 100644 --- a/osf_tests/metrics/reporters/test_institutional_users_reporter.py +++ b/osf_tests/metrics/reporters/test_institutional_users_reporter.py @@ -7,6 +7,7 @@ from api_tests.utils import create_test_file from osf import models as osfdb +from osf.management.commands.populate_notification_types import populate_notification_types from osf.metrics.reports import InstitutionalUserReport from osf.metrics.reporters import InstitutionalUsersReporter from osf.metrics.utils import YearMonth @@ -28,6 +29,7 @@ def _patch_now(fakenow: datetime.datetime): class TestInstiUsersReporter(TestCase): @classmethod def setUpTestData(cls): + populate_notification_types() cls._yearmonth = YearMonth(2012, 7) cls._now = datetime.datetime( cls._yearmonth.year, diff --git a/osf_tests/test_archiver.py b/osf_tests/test_archiver.py index 65ebc719789..309dfdbf9e9 100644 --- a/osf_tests/test_archiver.py +++ b/osf_tests/test_archiver.py @@ -12,8 +12,6 @@ from framework.auth import Auth from framework.celery_tasks import handlers -from website import mails - from website.archiver import ( ARCHIVER_INITIATED, ) @@ -22,7 +20,7 @@ from website.archiver import listeners from website.archiver.tasks import * # noqa: F403 -from osf.models import Guid, RegistrationSchema, Registration +from osf.models import Guid, RegistrationSchema, Registration, NotificationType from osf.models.archive import ArchiveTarget, ArchiveJob from osf.models.base import generate_object_id from osf.utils.migrations import map_schema_to_schemablocks @@ -32,8 +30,7 @@ from osf_tests import factories from tests.base import OsfTestCase, fake from tests import utils as test_utils -from tests.utils import unique as _unique -from conftest import start_mock_send_grid +from tests.utils import unique as _unique, capture_notifications pytestmark = pytest.mark.django_db @@ -549,7 +546,8 @@ def test_archive_success(self): prearchive_responses = registration.registration_responses with mock.patch.object(BaseStorageAddon, '_get_file_tree', mock.Mock(return_value=file_trees[node._id])): job = factories.ArchiveJobFactory(initiator=registration.creator) - archive_success(registration._id, job._id) + with capture_notifications(): + archive_success(registration._id, job._id) registration.refresh_from_db() for response_block in registration.schema_responses.get().response_blocks.all(): @@ -590,7 +588,8 @@ def test_archive_success_escaped_file_names(self): with test_utils.mock_archive(node, schema=schema, draft_registration=draft, autocomplete=True, autoapprove=True) as registration: with mock.patch.object(BaseStorageAddon, '_get_file_tree', mock.Mock(return_value=file_tree)): job = factories.ArchiveJobFactory(initiator=registration.creator) - archive_success(registration._id, job._id) + with capture_notifications(): + archive_success(registration._id, job._id) registration.refresh_from_db() updated_response = registration.schema_responses.get().all_responses[qid] assert updated_response[0]['file_name'] == fake_file_name @@ -619,7 +618,8 @@ def mock_get_file_tree(self, *args, **kwargs): with mock.patch.object(BaseStorageAddon, '_get_file_tree', mock_get_file_tree): job = factories.ArchiveJobFactory(initiator=registration.creator) - archive_success(registration._id, job._id) + with capture_notifications(): + archive_success(registration._id, job._id) registration.refresh_from_db() registration_files = set() @@ -658,7 +658,8 @@ def test_archive_success_different_name_same_sha(self): with test_utils.mock_archive(node, schema=schema, draft_registration=draft_registration, autocomplete=True, autoapprove=True) as registration: with mock.patch.object(BaseStorageAddon, '_get_file_tree', mock.Mock(return_value=file_tree)): job = factories.ArchiveJobFactory(initiator=registration.creator) - archive_success(registration._id, job._id) + with capture_notifications(): + archive_success(registration._id, job._id) for key, question in registration.registered_meta[schema._id].items(): assert question['extra'][0]['selectedFileName'] == fake_file['name'] @@ -714,52 +715,56 @@ def test_archive_success_same_file_in_component(self): with test_utils.mock_archive(node, schema=schema, draft_registration=draft_registration, autocomplete=True, autoapprove=True) as registration: with mock.patch.object(BaseStorageAddon, '_get_file_tree', mock.Mock(return_value=file_tree)): job = factories.ArchiveJobFactory(initiator=registration.creator) - archive_success(registration._id, job._id) + with capture_notifications(): + archive_success(registration._id, job._id) registration.reload() child_reg = registration.nodes[0] for key, question in registration.registered_meta[schema._id].items(): assert child_reg._id in question['extra'][0]['viewUrl'] -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestArchiverUtils(ArchiverTestCase): - def setUp(self): - super().setUp() - self.mock_send_grid = start_mock_send_grid(self) - def test_handle_archive_fail(self): - archiver_utils.handle_archive_fail( - ARCHIVER_NETWORK_ERROR, - self.src, - self.dst, - self.user, - {} - ) - assert self.mock_send_grid.call_count == 2 + with capture_notifications() as notifications: + archiver_utils.handle_archive_fail( + ARCHIVER_NETWORK_ERROR, + self.src, + self.dst, + self.user, + {} + ) + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.DESK_ARCHIVE_JOB_COPY_ERROR + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_ARCHIVE_JOB_COPY_ERROR self.dst.reload() assert self.dst.is_deleted def test_handle_archive_fail_copy(self): - archiver_utils.handle_archive_fail( - ARCHIVER_NETWORK_ERROR, - self.src, - self.dst, - self.user, - {} - ) - assert self.mock_send_grid.call_count == 2 + with capture_notifications() as notifications: + archiver_utils.handle_archive_fail( + ARCHIVER_NETWORK_ERROR, + self.src, + self.dst, + self.user, + {} + ) + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.DESK_ARCHIVE_JOB_COPY_ERROR + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_ARCHIVE_JOB_COPY_ERROR def test_handle_archive_fail_size(self): - archiver_utils.handle_archive_fail( - ARCHIVER_SIZE_EXCEEDED, - self.src, - self.dst, - self.user, - {} - ) - assert self.mock_send_grid.call_count == 2 + with capture_notifications() as notifications: + archiver_utils.handle_archive_fail( + ARCHIVER_SIZE_EXCEEDED, + self.src, + self.dst, + self.user, + {} + ) + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.DESK_ARCHIVE_JOB_EXCEEDED + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_ARCHIVE_JOB_EXCEEDED def test_aggregate_file_tree_metadata(self): a_stat_result = archiver_utils.aggregate_file_tree_metadata('dropbox', FILE_TREE, self.user) @@ -846,14 +851,8 @@ def test_get_file_map_memoization(self): archiver_utils.get_file_map(node) assert mock_get_file_tree.call_count == call_count -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestArchiverListeners(ArchiverTestCase): - def setUp(self): - super().setUp() - self.mock_send_grid = start_mock_send_grid(self) - @mock.patch('website.archiver.tasks.archive') @mock.patch('website.archiver.utils.before_archive') def test_after_register(self, mock_before_archive, mock_archive): @@ -906,7 +905,6 @@ def test_archive_callback_pending(self, mock_delay): self.dst.archive_job.save() with mock.patch('website.archiver.utils.handle_archive_fail') as mock_fail: listeners.archive_callback(self.dst) - assert not self.mock_send_grid.called assert not mock_fail.called assert mock_delay.called @@ -915,7 +913,6 @@ def test_archive_callback_done_success(self, mock_archive_success): self.dst.archive_job.update_target('osfstorage', ARCHIVER_SUCCESS) self.dst.archive_job.save() listeners.archive_callback(self.dst) - assert self.mock_send_grid.call_count == 0 @mock.patch('website.archiver.tasks.archive_success.delay') def test_archive_callback_done_embargoed(self, mock_archive_success): @@ -930,7 +927,6 @@ def test_archive_callback_done_embargoed(self, mock_archive_success): self.dst.archive_job.update_target('osfstorage', ARCHIVER_SUCCESS) self.dst.save() listeners.archive_callback(self.dst) - assert self.mock_send_grid.call_count == 0 def test_archive_callback_done_errors(self): self.dst.archive_job.update_target('osfstorage', ARCHIVER_FAILURE) @@ -1022,15 +1018,12 @@ def test_archive_callback_on_tree_sends_only_one_email(self, mock_arhive_success rchild.archive_job.update_target('osfstorage', ARCHIVER_SUCCESS) rchild.save() listeners.archive_callback(rchild) - assert not self.mock_send_grid.called reg.archive_job.update_target('osfstorage', ARCHIVER_SUCCESS) reg.save() listeners.archive_callback(reg) - assert not self.mock_send_grid.called rchild2.archive_job.update_target('osfstorage', ARCHIVER_SUCCESS) rchild2.save() listeners.archive_callback(rchild2) - assert not self.mock_send_grid.called class TestArchiverScripts(ArchiverTestCase): @@ -1078,14 +1071,8 @@ def test_find_failed_registrations(self): assert pk not in failed -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestArchiverBehavior(OsfTestCase): - def setUp(self): - super().setUp() - self.mock_send_grid = start_mock_send_grid(self) - @mock.patch('osf.models.AbstractNode.update_search') def test_archiving_registrations_not_added_to_search_before_archival(self, mock_update_search): proj = factories.ProjectFactory() @@ -1112,12 +1099,12 @@ def test_archiving_nodes_not_added_to_search_on_archive_failure(self, mock_delet proj = factories.ProjectFactory() reg = factories.RegistrationFactory(project=proj, archive=True) reg.save() - with nested( + with capture_notifications(): + with nested( mock.patch('osf.models.archive.ArchiveJob.archive_tree_finished', mock.Mock(return_value=True)), mock.patch('osf.models.archive.ArchiveJob.success', mock.PropertyMock(return_value=False)) - ) as (mock_finished, mock_success): - listeners.archive_callback(reg) - assert mock_delete_index_node.called + ) as (mock_finished, mock_success): + listeners.archive_callback(reg) @mock.patch('osf.models.AbstractNode.update_search') def test_archiving_nodes_not_added_to_search_on_archive_incomplete(self, mock_update_search): @@ -1209,17 +1196,3 @@ def test_archive_tree_finished_with_nodes(self): node.archive_job.update_target(target.name, ARCHIVER_SUCCESS) for node in reg.node_and_primary_descendants(): assert node.archive_job.archive_tree_finished() - -# Regression test for https://openscience.atlassian.net/browse/OSF-9085 -def test_archiver_uncaught_error_mail_renders(): - src = factories.ProjectFactory() - user = src.creator - job = factories.ArchiveJobFactory() - mail = mails.ARCHIVE_UNCAUGHT_ERROR_DESK - assert mail.html( - user=user, - src=src, - results=job.target_addons.all(), - url=settings.INTERNAL_DOMAIN + src._id, - can_change_preferences=False, - ) diff --git a/osf_tests/test_auth_utils.py b/osf_tests/test_auth_utils.py index ff3aa9484f2..a0d35c7f8b0 100644 --- a/osf_tests/test_auth_utils.py +++ b/osf_tests/test_auth_utils.py @@ -1,10 +1,10 @@ import pytest from framework.auth.core import get_user +from tests.utils import capture_notifications from .factories import UserFactory -# from tests/test_auth @pytest.mark.django_db class TestGetUser: @@ -15,7 +15,8 @@ def test_get_user_by_email(self): def test_get_user_with_wrong_password_returns_false(self): user = UserFactory.build() - user.set_password('killerqueen') + with capture_notifications(): + user.set_password('killerqueen') assert bool( get_user(email=user.username, password='wrong') ) is False diff --git a/osf_tests/test_collection.py b/osf_tests/test_collection.py index c28dea3eb99..5eba83ac530 100644 --- a/osf_tests/test_collection.py +++ b/osf_tests/test_collection.py @@ -5,8 +5,9 @@ from framework.auth import Auth -from osf.models import Collection +from osf.models import Collection, NotificationType from osf.exceptions import NodeStateError +from tests.utils import capture_notifications from website.views import find_bookmark_collection from .factories import ( UserFactory, @@ -71,7 +72,6 @@ def test_can_remove_root_folder_structure_without_cascading(self, user, auth): @pytest.mark.enable_bookmark_creation -@pytest.mark.usefixtures('mock_send_grid') class TestImplicitRemoval: @pytest.fixture @@ -111,7 +111,8 @@ def provider_collected_node(self, bookmark_collection, alternate_bookmark_collec node = ProjectFactory(creator=bookmark_collection.creator, is_public=True) bookmark_collection.collect_object(node, bookmark_collection.creator) alternate_bookmark_collection.collect_object(node, alternate_bookmark_collection.creator) - provider_collection.collect_object(node, provider_collection.creator) + with capture_notifications(): + provider_collection.collect_object(node, provider_collection.creator) return node @mock.patch('osf.models.node.Node.check_privacy_change_viability', mock.Mock()) # mocks the storage usage limits @@ -126,22 +127,21 @@ def test_node_removed_from_collection_on_privacy_change(self, auth, collected_no assert associated_collections.filter(collection=bookmark_collection).exists() @mock.patch('osf.models.node.Node.check_privacy_change_viability', mock.Mock()) # mocks the storage usage limits - def test_node_removed_from_collection_on_privacy_change_notify(self, auth, provider_collected_node, bookmark_collection, mock_send_grid): + def test_node_removed_from_collection_on_privacy_change_notify(self, auth, provider_collected_node, bookmark_collection): associated_collections = provider_collected_node.guids.first().collectionsubmission_set assert associated_collections.count() == 3 - mock_send_grid.reset_mock() - provider_collected_node.set_privacy('private', auth=auth) - assert mock_send_grid.called - assert len(mock_send_grid.call_args_list) == 1 + with capture_notifications() as notifications: + provider_collected_node.set_privacy('private', auth=auth) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_PRIVATE @mock.patch('osf.models.node.Node.check_privacy_change_viability', mock.Mock()) # mocks the storage usage limits - def test_node_removed_from_collection_on_privacy_change_no_provider(self, auth, collected_node, bookmark_collection, mock_send_grid): + def test_node_removed_from_collection_on_privacy_change_no_provider(self, auth, collected_node, bookmark_collection): associated_collections = collected_node.guids.first().collectionsubmission_set assert associated_collections.count() == 3 collected_node.set_privacy('private', auth=auth) - assert not mock_send_grid.called def test_node_removed_from_collection_on_delete(self, collected_node, bookmark_collection, auth): associated_collections = collected_node.guids.first().collectionsubmission_set diff --git a/osf_tests/test_collection_submission.py b/osf_tests/test_collection_submission.py index 2ff2b279a6b..478a437f5a5 100644 --- a/osf_tests/test_collection_submission.py +++ b/osf_tests/test_collection_submission.py @@ -1,4 +1,3 @@ -from unittest import mock import pytest from osf_tests.factories import ( @@ -9,13 +8,15 @@ from osf_tests.factories import NodeFactory, CollectionFactory, CollectionProviderFactory -from osf.models import CollectionSubmission +from osf.models import CollectionSubmission, NotificationType from osf.utils.workflows import CollectionSubmissionStates from framework.exceptions import PermissionsError from api_tests.utils import UserRoles -from osf.management.commands.populate_collection_provider_notification_subscriptions import populate_collection_provider_notification_subscriptions from django.utils import timezone +from tests.utils import capture_notifications + + @pytest.fixture def user(): return AuthUserFactory() @@ -95,7 +96,8 @@ def moderated_collection_submission(node, moderated_collection): collection=moderated_collection, creator=node.creator, ) - collection_submission.save() + with capture_notifications(): + collection_submission.save() assert collection_submission.is_moderated return collection_submission @@ -107,7 +109,8 @@ def unmoderated_collection_submission(node, unmoderated_collection): collection=unmoderated_collection, creator=node.creator, ) - collection_submission.save() + with capture_notifications(): + collection_submission.save() assert not collection_submission.is_moderated return collection_submission @@ -118,7 +121,8 @@ def hybrid_moderated_collection_submission(node, hybrid_moderated_collection): collection=hybrid_moderated_collection, creator=node.creator, ) - collection_submission.save() + with capture_notifications(): + collection_submission.save() assert collection_submission.is_hybrid_moderated return collection_submission @@ -144,55 +148,40 @@ def configure_test_auth(node, user_role, provider=None): @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestModeratedCollectionSubmission: MOCK_NOW = timezone.now() - @pytest.fixture(autouse=True) - def setup(self): - populate_collection_provider_notification_subscriptions() - with mock.patch('osf.utils.machines.timezone.now', return_value=self.MOCK_NOW): - yield - def test_submit(self, moderated_collection_submission): # .submit on post_save assert moderated_collection_submission.state == CollectionSubmissionStates.PENDING - def test_notify_contributors_pending(self, node, moderated_collection, mock_send_grid): - collection_submission = CollectionSubmission( - guid=node.guids.first(), - collection=moderated_collection, - creator=node.creator, - ) - collection_submission.save() - assert mock_send_grid.called + def test_notify_contributors_pending(self, node, moderated_collection): + with capture_notifications() as notifications: + collection_submission = CollectionSubmission( + guid=node.guids.first(), + collection=moderated_collection, + creator=node.creator, + ) + collection_submission.save() + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_SUBMITTED + assert notifications['emits'][1]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS assert collection_submission.state == CollectionSubmissionStates.PENDING def test_notify_moderators_pending(self, node, moderated_collection): - from website.notifications import emails - store_emails = emails.store_emails - with mock.patch('website.notifications.emails.store_emails') as mock_store_emails: - mock_store_emails.side_effect = store_emails # implicitly test rendering + + with capture_notifications() as notifications: collection_submission = CollectionSubmission( guid=node.guids.first(), collection=moderated_collection, creator=node.creator, ) - populate_collection_provider_notification_subscriptions() collection_submission.save() - assert mock_store_emails.called + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_SUBMITTED + assert notifications['emits'][1]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS assert collection_submission.state == CollectionSubmissionStates.PENDING - email_call = mock_store_emails.call_args_list[0][0] - moderator = moderated_collection.moderators.get() - assert email_call == ( - [moderator._id], - 'email_transactional', - 'new_pending_submissions', - collection_submission.creator, - node, - self.MOCK_NOW, - ) @pytest.mark.parametrize('user_role', [UserRoles.UNAUTHENTICATED, UserRoles.NONCONTRIB]) def test_accept_fails(self, user_role, moderated_collection_submission): @@ -203,13 +192,17 @@ def test_accept_fails(self, user_role, moderated_collection_submission): def test_accept_success(self, node, moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - moderated_collection_submission.accept(user=moderator, comment='Test Comment') + with capture_notifications(): + moderated_collection_submission.accept(user=moderator, comment='Test Comment') assert moderated_collection_submission.state == CollectionSubmissionStates.ACCEPTED - def test_notify_moderated_accepted(self, node, moderated_collection_submission, mock_send_grid): + def test_notify_moderated_accepted(self, node, moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - moderated_collection_submission.accept(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + moderated_collection_submission.accept(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_ACCEPTED + assert moderated_collection_submission.state == CollectionSubmissionStates.ACCEPTED @pytest.mark.parametrize('user_role', [UserRoles.UNAUTHENTICATED, UserRoles.NONCONTRIB]) @@ -221,14 +214,18 @@ def test_reject_fails(self, node, user_role, moderated_collection_submission): def test_reject_success(self, node, moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - moderated_collection_submission.reject(user=moderator, comment='Test Comment') + with capture_notifications(): + moderated_collection_submission.reject(user=moderator, comment='Test Comment') assert moderated_collection_submission.state == CollectionSubmissionStates.REJECTED - def test_notify_moderated_rejected(self, node, moderated_collection_submission, mock_send_grid): + def test_notify_moderated_rejected(self, node, moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - moderated_collection_submission.reject(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + moderated_collection_submission.reject(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REJECTED + assert moderated_collection_submission.state == CollectionSubmissionStates.REJECTED @pytest.mark.parametrize('user_role', UserRoles.excluding(*[UserRoles.ADMIN_USER, UserRoles.MODERATOR])) @@ -245,30 +242,39 @@ def test_remove_success(self, node, user_role, moderated_collection_submission): user = configure_test_auth(node, user_role) moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) moderated_collection_submission.save() - moderated_collection_submission.remove(user=user, comment='Test Comment') + with capture_notifications(): + moderated_collection_submission.remove(user=user, comment='Test Comment') assert moderated_collection_submission.state == CollectionSubmissionStates.REMOVED - def test_notify_moderated_removed_moderator(self, node, moderated_collection_submission, mock_send_grid): + def test_notify_moderated_removed_moderator(self, node, moderated_collection_submission): moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) moderator = configure_test_auth(node, UserRoles.MODERATOR) - moderated_collection_submission.remove(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + moderated_collection_submission.remove(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_MODERATOR + assert moderated_collection_submission.state == CollectionSubmissionStates.REMOVED - def test_notify_moderated_removed_admin(self, node, moderated_collection_submission, mock_send_grid): + def test_notify_moderated_removed_admin(self, node, moderated_collection_submission): moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) moderator = configure_test_auth(node, UserRoles.ADMIN_USER) - moderated_collection_submission.remove(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + moderated_collection_submission.remove(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 2 + assert notifications['emits'][1]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN + assert moderated_collection_submission.state == CollectionSubmissionStates.REMOVED def test_resubmit_success(self, node, moderated_collection_submission): user = configure_test_auth(node, UserRoles.ADMIN_USER) moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.REMOVED) moderated_collection_submission.save() - moderated_collection_submission.resubmit(user=user, comment='Test Comment') + with capture_notifications(): + moderated_collection_submission.resubmit(user=user, comment='Test Comment') assert moderated_collection_submission.state == CollectionSubmissionStates.PENDING @pytest.mark.parametrize('user_role', UserRoles.excluding(UserRoles.ADMIN_USER)) @@ -293,12 +299,12 @@ def test_cancel_succeeds(self, node, moderated_collection_submission): user = configure_test_auth(node, UserRoles.ADMIN_USER) moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.PENDING) moderated_collection_submission.save() - moderated_collection_submission.cancel(user=user, comment='Test Comment') + with capture_notifications(): + moderated_collection_submission.cancel(user=user, comment='Test Comment') assert moderated_collection_submission.state == CollectionSubmissionStates.IN_PROGRESS @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestUnmoderatedCollectionSubmission: def test_moderated_submit(self, unmoderated_collection_submission): @@ -333,22 +339,27 @@ def test_remove_success(self, user_role, node, unmoderated_collection_submission user = configure_test_auth(node, user_role) unmoderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) unmoderated_collection_submission.save() - unmoderated_collection_submission.remove(user=user, comment='Test Comment') + with capture_notifications(): + unmoderated_collection_submission.remove(user=user, comment='Test Comment') assert unmoderated_collection_submission.state == CollectionSubmissionStates.REMOVED - def test_notify_moderated_removed_admin(self, node, unmoderated_collection_submission, mock_send_grid): + def test_notify_moderated_removed_admin(self, node, unmoderated_collection_submission): unmoderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) moderator = configure_test_auth(node, UserRoles.ADMIN_USER) - unmoderated_collection_submission.remove(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + unmoderated_collection_submission.remove(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN + assert notifications['emits'][1]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN assert unmoderated_collection_submission.state == CollectionSubmissionStates.REMOVED def test_resubmit_success(self, node, unmoderated_collection_submission): user = configure_test_auth(node, UserRoles.ADMIN_USER) unmoderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.REMOVED) unmoderated_collection_submission.save() - unmoderated_collection_submission.resubmit(user=user, comment='Test Comment') + with capture_notifications(): + unmoderated_collection_submission.resubmit(user=user, comment='Test Comment') assert unmoderated_collection_submission.state == CollectionSubmissionStates.ACCEPTED @pytest.mark.parametrize('user_role', UserRoles.excluding(UserRoles.ADMIN_USER)) @@ -373,12 +384,12 @@ def test_cancel_succeeds(self, node, unmoderated_collection_submission): user = configure_test_auth(node, UserRoles.ADMIN_USER) unmoderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.PENDING) unmoderated_collection_submission.save() - unmoderated_collection_submission.cancel(user=user, comment='Test Comment') + with capture_notifications(): + unmoderated_collection_submission.cancel(user=user, comment='Test Comment') assert unmoderated_collection_submission.state == CollectionSubmissionStates.IN_PROGRESS @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestHybridModeratedCollectionSubmission: @pytest.mark.parametrize('user_role', UserRoles.excluding(UserRoles.MODERATOR)) @@ -389,8 +400,8 @@ def test_hybrid_submit(self, user_role, node, hybrid_moderated_collection): collection=hybrid_moderated_collection, creator=node.creator, ) - - collection_submission.save() + with capture_notifications(): + collection_submission.save() assert collection_submission.is_hybrid_moderated # .submit on post_save assert collection_submission.state == CollectionSubmissionStates.PENDING @@ -405,7 +416,8 @@ def test_hybrid_submit_moderator_not_submitted(self, user_role, node, hybrid_mod collection=hybrid_moderated_collection, creator=not_admin_moderator, ) - collection_submission.save() + with capture_notifications(): + collection_submission.save() assert collection_submission.is_hybrid_moderated assert collection_submission.state == CollectionSubmissionStates.PENDING @@ -418,7 +430,8 @@ def test_hybrid_submit_moderator_submitted(self, user_role, node, hybrid_moderat collection=hybrid_moderated_collection, creator=user, ) - collection_submission.save() + with capture_notifications(): + collection_submission.save() assert collection_submission.is_hybrid_moderated assert collection_submission.state == CollectionSubmissionStates.ACCEPTED @@ -431,14 +444,17 @@ def test_accept_fails(self, user_role, hybrid_moderated_collection_submission): def test_accept_success(self, node, hybrid_moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - hybrid_moderated_collection_submission.accept(user=moderator, comment='Test Comment') + with capture_notifications(): + hybrid_moderated_collection_submission.accept(user=moderator, comment='Test Comment') assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.ACCEPTED - def test_notify_moderated_accepted(self, node, hybrid_moderated_collection_submission, mock_send_grid): + def test_notify_moderated_accepted(self, node, hybrid_moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - hybrid_moderated_collection_submission.accept(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + hybrid_moderated_collection_submission.accept(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_ACCEPTED assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.ACCEPTED @pytest.mark.parametrize('user_role', [UserRoles.UNAUTHENTICATED, UserRoles.NONCONTRIB]) @@ -450,14 +466,17 @@ def test_reject_fails(self, node, user_role, hybrid_moderated_collection_submiss def test_reject_success(self, node, hybrid_moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - hybrid_moderated_collection_submission.reject(user=moderator, comment='Test Comment') + with capture_notifications(): + hybrid_moderated_collection_submission.reject(user=moderator, comment='Test Comment') assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.REJECTED - def test_notify_moderated_rejected(self, node, hybrid_moderated_collection_submission, mock_send_grid): + def test_notify_moderated_rejected(self, node, hybrid_moderated_collection_submission): moderator = configure_test_auth(node, UserRoles.MODERATOR) - hybrid_moderated_collection_submission.reject(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + hybrid_moderated_collection_submission.reject(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REJECTED assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.REJECTED @pytest.mark.parametrize('user_role', UserRoles.excluding(*[UserRoles.ADMIN_USER, UserRoles.MODERATOR])) @@ -474,30 +493,38 @@ def test_remove_success(self, node, user_role, hybrid_moderated_collection_submi user = configure_test_auth(node, user_role) hybrid_moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) hybrid_moderated_collection_submission.save() - hybrid_moderated_collection_submission.remove(user=user, comment='Test Comment') + with capture_notifications(): + hybrid_moderated_collection_submission.remove(user=user, comment='Test Comment') assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.REMOVED - def test_notify_moderated_removed_moderator(self, node, hybrid_moderated_collection_submission, mock_send_grid): + def test_notify_moderated_removed_moderator(self, node, hybrid_moderated_collection_submission): hybrid_moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) moderator = configure_test_auth(node, UserRoles.MODERATOR) - hybrid_moderated_collection_submission.remove(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + hybrid_moderated_collection_submission.remove(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_MODERATOR assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.REMOVED - def test_notify_moderated_removed_admin(self, node, hybrid_moderated_collection_submission, mock_send_grid): + def test_notify_moderated_removed_admin(self, node, hybrid_moderated_collection_submission): hybrid_moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.ACCEPTED) moderator = configure_test_auth(node, UserRoles.ADMIN_USER) - hybrid_moderated_collection_submission.remove(user=moderator, comment='Test Comment') - assert mock_send_grid.called + with capture_notifications() as notifications: + hybrid_moderated_collection_submission.remove(user=moderator, comment='Test Comment') + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN + assert notifications['emits'][1]['type'] == NotificationType.Type.COLLECTION_SUBMISSION_REMOVED_ADMIN + assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.REMOVED def test_resubmit_success(self, node, hybrid_moderated_collection_submission): user = configure_test_auth(node, UserRoles.ADMIN_USER) hybrid_moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.REMOVED) hybrid_moderated_collection_submission.save() - hybrid_moderated_collection_submission.resubmit(user=user, comment='Test Comment') + with capture_notifications(): + hybrid_moderated_collection_submission.resubmit(user=user, comment='Test Comment') assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.PENDING @pytest.mark.parametrize('user_role', UserRoles.excluding(UserRoles.ADMIN_USER)) @@ -527,7 +554,8 @@ def test_hybrid_resubmit_moderator_submitted(self, node, hybrid_moderated_collec node.add_contributor(user) hybrid_moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.REMOVED) hybrid_moderated_collection_submission.save() - hybrid_moderated_collection_submission.resubmit(user=user, comment='Test Comment') + with capture_notifications(): + hybrid_moderated_collection_submission.resubmit(user=user, comment='Test Comment') assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.ACCEPTED @pytest.mark.parametrize('user_role', UserRoles.excluding(UserRoles.ADMIN_USER)) @@ -543,5 +571,6 @@ def test_cancel_succeeds(self, node, hybrid_moderated_collection_submission): user = configure_test_auth(node, UserRoles.ADMIN_USER) hybrid_moderated_collection_submission.state_machine.set_state(CollectionSubmissionStates.PENDING) hybrid_moderated_collection_submission.save() - hybrid_moderated_collection_submission.cancel(user=user, comment='Test Comment') + with capture_notifications(): + hybrid_moderated_collection_submission.cancel(user=user, comment='Test Comment') assert hybrid_moderated_collection_submission.state == CollectionSubmissionStates.IN_PROGRESS diff --git a/osf_tests/test_comment.py b/osf_tests/test_comment.py index 7f247d403d5..14703cb5fc4 100644 --- a/osf_tests/test_comment.py +++ b/osf_tests/test_comment.py @@ -3,20 +3,10 @@ import pytest import datetime from django.utils import timezone -from collections import OrderedDict - -from addons.box.models import BoxFile -from addons.dropbox.models import DropboxFile -from addons.github.models import GithubFile -from addons.googledrive.models import GoogleDriveFile -from addons.osfstorage.models import OsfStorageFile -from addons.s3.models import S3File from website import settings -from addons.osfstorage import settings as osfstorage_settings -from website.project.views.comment import update_file_guid_referent from framework.exceptions import PermissionsError from tests.base import capture_signals -from osf.models import Comment, NodeLog, Guid, BaseFileNode +from osf.models import Comment, NodeLog, Guid from osf.utils import permissions from framework.auth.core import Auth from .factories import ( @@ -66,8 +56,13 @@ def comment(user, project): def unreg_contributor(project): unreg_user = UnregUserFactory() unreg_user.save() - project.add_unregistered_contributor(unreg_user.fullname, unreg_user.email, Auth(project.creator), - permissions=permissions.READ, save=True) + project.add_unregistered_contributor( + unreg_user.fullname, + unreg_user.email, + Auth(project.creator), + permissions=permissions.READ, + notification_type=False + ) return unreg_user @@ -391,676 +386,3 @@ def test_find_unread_does_not_include_deleted_comments(self): CommentFactory(node=project, user=project.creator, is_deleted=True) n_unread = Comment.find_n_unread(user=user, node=project, page='node') assert n_unread == 0 - - -# copied from tests/test_comments.py -class FileCommentMoveRenameTestMixin: - id_based_providers = ['osfstorage'] - - @pytest.fixture() - def project(self, user): - p = ProjectFactory(creator=user) - p_settings = p.get_or_add_addon(self.provider, Auth(user)) - p_settings.folder = '/Folder1' - p_settings.save() - p.save() - return p - - @pytest.fixture() - def component(self, user, project): - c = NodeFactory(parent=project, creator=user) - c_settings = c.get_or_add_addon(self.provider, Auth(user)) - c_settings.folder = '/Folder2' - c_settings.save() - c.save() - return c - - @property - def provider(self): - raise NotImplementedError - - @property - def ProviderFile(self): - raise NotImplementedError - - @classmethod - def _format_path(cls, path, file_id=None): - return path - - def _create_source_payload(self, path, node, provider, file_id=None): - return OrderedDict([('materialized', path), - ('name', path.split('/')[-1]), - ('nid', node._id), - ('path', self._format_path(path, file_id)), - ('provider', provider), - ('url', '/project/{}/files/{}/{}/'.format(node._id, provider, path.strip('/'))), - ('node', {'url': f'/{node._id}/', '_id': node._id, 'title': node.title}), - ('addon', provider)]) - - def _create_destination_payload(self, path, node, provider, file_id, children=None): - destination_path = PROVIDER_CLASS.get(provider)._format_path(path=path, file_id=file_id) - destination = OrderedDict([('contentType', ''), - ('etag', 'abcdefghijklmnop'), - ('extra', OrderedDict([('revisionId', '12345678910')])), - ('kind', 'file'), - ('materialized', path), - ('modified', 'Tue, 02 Feb 2016 17:55:48 +0000'), - ('name', path.split('/')[-1]), - ('nid', node._id), - ('path', destination_path), - ('provider', provider), - ('size', 1000), - ('url', '/project/{}/files/{}/{}/'.format(node._id, provider, path.strip('/'))), - ('node', {'url': f'/{node._id}/', '_id': node._id, 'title': node.title}), - ('addon', provider)]) - if children: - destination_children = [self._create_destination_payload(child['path'], child['node'], child['provider'], file_id) for child in children] - destination.update({'children': destination_children}) - return destination - - def _create_payload(self, action, user, source, destination, file_id, destination_file_id=None): - return OrderedDict([ - ('action', action), - ('auth', OrderedDict([('email', user.username), ('id', user._id), ('name', user.fullname)])), - ('destination', self._create_destination_payload(path=destination['path'], - node=destination['node'], - provider=destination['provider'], - file_id=destination_file_id or file_id, - children=destination.get('children', []))), - ('source', self._create_source_payload(source['path'], source['node'], source['provider'], file_id=file_id)), - ('time', 100000000), - ('node', source['node']), - ('project', None) - ]) - - def _create_file_with_comment(self, node, path, user): - self.file = self.ProviderFile.create( - target=node, - path=path, - name=path.strip('/'), - materialized_path=path) - self.file.save() - self.guid = self.file.get_guid(create=True) - self.comment = CommentFactory(user=user, node=node, target=self.guid) - - def test_comments_move_on_file_rename(self, project, user): - source = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/file_renamed.txt', - 'node': project, - 'provider': self.provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_renamed', payload=payload) - self.guid.reload() - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path(destination['path'], file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_on_folder_rename(self, project, user): - source = { - 'path': '/subfolder1/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder2/', - 'node': project, - 'provider': self.provider - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_renamed', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_name), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_on_subfolder_file_when_parent_folder_is_renamed(self, project, user): - source = { - 'path': '/subfolder1/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder2/', - 'node': project, - 'provider': self.provider - } - file_path = 'sub-subfolder/file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_path), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_renamed', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_path), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_file_moved_to_subfolder(self, project, user): - source = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/file.txt', - 'node': project, - 'provider': self.provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path(destination['path'], file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_file_moved_from_subfolder_to_root(self, project, user): - source = { - 'path': '/subfolder/file.txt', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path(destination['path'], file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_file_moved_from_project_to_component(self, project, component, user): - source = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/file.txt', - 'node': component, - 'provider': self.provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path(destination['path'], file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - assert self.guid.referent.target._id == destination['node']._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_file_moved_from_component_to_project(self, project, component, user): - source = { - 'path': '/file.txt', - 'node': component, - 'provider': self.provider - } - destination = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path(destination['path'], file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - assert self.guid.referent.target._id == destination['node']._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_folder_moved_to_subfolder(self, user, project): - source = { - 'path': '/subfolder/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder2/subfolder/', - 'node': project, - 'provider': self.provider - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_name), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_folder_moved_from_subfolder_to_root(self, project, user): - source = { - 'path': '/subfolder2/subfolder/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/', - 'node': project, - 'provider': self.provider - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_name), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_folder_moved_from_project_to_component(self, project, component, user): - source = { - 'path': '/subfolder/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/', - 'node': component, - 'provider': self.provider - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_name), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_folder_moved_from_component_to_project(self, project, component, user): - source = { - 'path': '/subfolder/', - 'node': component, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/', - 'node': project, - 'provider': self.provider - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_name), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_file_moved_to_osfstorage(self, project, user): - osfstorage = project.get_addon('osfstorage') - root_node = osfstorage.get_root() - osf_file = root_node.append_file('file.txt') - osf_file.create_version(user, { - 'object': '06d80e', - 'service': 'cloud', - osfstorage_settings.WATERBUTLER_RESOURCE: 'osf', - }, { - 'size': 1337, - 'contentType': 'img/png', - 'etag': 'abcdefghijklmnop' - }).save() - - source = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': osf_file.path, - 'node': project, - 'provider': 'osfstorage' - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - payload = self._create_payload('move', user, source, destination, self.file._id, destination_file_id=destination['path'].strip('/')) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class('osfstorage', BaseFileNode.FILE).get_or_create(destination['node'], destination['path']) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_folder_moved_to_osfstorage(self, project, user): - osfstorage = project.get_addon('osfstorage') - root_node = osfstorage.get_root() - osf_folder = root_node.append_folder('subfolder') - osf_file = osf_folder.append_file('file.txt') - osf_file.create_version(user, { - 'object': '06d80e', - 'service': 'cloud', - osfstorage_settings.WATERBUTLER_RESOURCE: 'osf', - }, { - 'size': 1337, - 'contentType': 'img/png', - 'etag': '1234567890abcde' - }).save() - - source = { - 'path': '/subfolder/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/', - 'node': project, - 'provider': 'osfstorage', - 'children': [{ - 'path': '/subfolder/file.txt', - 'node': project, - 'provider': 'osfstorage' - }] - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id, destination_file_id=osf_file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class('osfstorage', BaseFileNode.FILE).get_or_create(destination['node'], osf_file._id) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - @pytest.mark.parametrize( - ['destination_provider', 'destination_path'], - [('box', '/1234567890'), ('dropbox', '/file.txt'), ('github', '/file.txt'), ('googledrive', '/file.txt'), ('s3', '/file.txt')] - ) - def test_comments_move_when_file_moved_to_different_provider(self, destination_provider, destination_path, project, user): - if self.provider == destination_provider: - assert True - return - - project.add_addon(destination_provider, auth=Auth(user)) - project.save() - self.addon_settings = project.get_addon(destination_provider) - self.addon_settings.folder = '/AddonFolder' - self.addon_settings.save() - - source = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': destination_path, - 'node': project, - 'provider': destination_provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(destination_provider, BaseFileNode.FILE).get_or_create(destination['node'], destination['path']) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - @pytest.mark.parametrize( - ['destination_provider', 'destination_path'], - [('box', '/1234567890'), ('dropbox', '/subfolder/file.txt'), ('github', '/subfolder/file.txt'), ('googledrive', '/subfolder/file.txt'), ('s3', '/subfolder/file.txt'), ] - ) - def test_comments_move_when_folder_moved_to_different_provider(self, destination_provider, destination_path, project, user): - if self.provider == destination_provider: - assert True - return - - project.add_addon(destination_provider, auth=Auth(user)) - project.save() - self.addon_settings = project.get_addon(destination_provider) - self.addon_settings.folder = '/AddonFolder' - self.addon_settings.save() - - source = { - 'path': '/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/', - 'node': project, - 'provider': destination_provider, - 'children': [{ - 'path': '/subfolder/file.txt', - 'node': project, - 'provider': destination_provider - }] - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(destination_provider, BaseFileNode.FILE).get_or_create(destination['node'], destination_path) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - -# copied from tests/test_comments.py -class TestOsfstorageFileCommentMoveRename(FileCommentMoveRenameTestMixin): - - provider = 'osfstorage' - ProviderFile = OsfStorageFile - - @classmethod - def _format_path(cls, path, file_id=None): - super()._format_path(path) - return '/{}{}'.format(file_id, ('/' if path.endswith('/') else '')) - - def _create_file_with_comment(self, node, path, user): - osfstorage = node.get_addon(self.provider) - root_node = osfstorage.get_root() - self.file = root_node.append_file('file.txt') - self.file.create_version(user, { - 'object': '06d80e', - 'service': 'cloud', - osfstorage_settings.WATERBUTLER_RESOURCE: 'osf', - }, { - 'size': 1337, - 'contentType': 'img/png', - 'etag': 'abcdefghijklmnop' - }).save() - self.file.materialized_path = path - self.guid = self.file.get_guid(create=True) - self.comment = CommentFactory(user=user, node=node, target=self.guid) - - def test_comments_move_when_file_moved_from_project_to_component(self, project, component, user): - source = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/file.txt', - 'node': component, - 'provider': self.provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - self.file.move_under(destination['node'].get_addon(self.provider).get_root()) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path(destination['path'], file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - assert self.guid.referent.target._id == destination['node']._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_file_moved_from_component_to_project(self, project, component, user): - source = { - 'path': '/file.txt', - 'node': component, - 'provider': self.provider - } - destination = { - 'path': '/file.txt', - 'node': project, - 'provider': self.provider - } - self._create_file_with_comment(node=source['node'], path=source['path'], user=user) - self.file.move_under(destination['node'].get_addon(self.provider).get_root()) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path(destination['path'], file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - assert self.guid.referent.target._id == destination['node']._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_folder_moved_from_project_to_component(self, project, component, user): - source = { - 'path': '/subfolder/', - 'node': project, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/', - 'node': component, - 'provider': self.provider - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - self.file.move_under(destination['node'].get_addon(self.provider).get_root()) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_name), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_folder_moved_from_component_to_project(self, project, component, user): - source = { - 'path': '/subfolder/', - 'node': component, - 'provider': self.provider - } - destination = { - 'path': '/subfolder/', - 'node': project, - 'provider': self.provider - } - file_name = 'file.txt' - self._create_file_with_comment(node=source['node'], path='{}{}'.format(source['path'], file_name), user=user) - self.file.move_under(destination['node'].get_addon(self.provider).get_root()) - payload = self._create_payload('move', user, source, destination, self.file._id) - update_file_guid_referent(self=None, target=destination['node'], event_type='addon_file_moved', payload=payload) - self.guid.reload() - - file_node = BaseFileNode.resolve_class(self.provider, BaseFileNode.FILE).get_or_create(destination['node'], self._format_path('{}{}'.format(destination['path'], file_name), file_id=self.file._id)) - assert self.guid._id == file_node.get_guid()._id - file_comments = Comment.objects.filter(root_target=self.guid.pk) - assert file_comments.count() == 1 - - def test_comments_move_when_file_moved_to_osfstorage(self): - # Already in OSFStorage - pass - - def test_comments_move_when_folder_moved_to_osfstorage(self): - # Already in OSFStorage - pass - -# copied from tests/test_comments.py -class TestBoxFileCommentMoveRename(FileCommentMoveRenameTestMixin): - - provider = 'box' - ProviderFile = BoxFile - - def _create_file_with_comment(self, node, path, user): - self.file = self.ProviderFile.create( - target=node, - path=self._format_path(path), - name=path.strip('/'), - materialized_path=path) - self.file.save() - self.guid = self.file.get_guid(create=True) - self.comment = CommentFactory(user=user, node=node, target=self.guid) - - @classmethod - def _format_path(cls, path, file_id=None): - super()._format_path(path) - return '/9876543210/' if path.endswith('/') else '/1234567890' - - -class TestDropboxFileCommentMoveRename(FileCommentMoveRenameTestMixin): - - provider = 'dropbox' - ProviderFile = DropboxFile - - def _create_file_with_comment(self, node, path, user): - self.file = self.ProviderFile.create( - target=node, - path=f'{node.get_addon(self.provider).folder}{path}', - name=path.strip('/'), - materialized_path=path) - self.file.save() - self.guid = self.file.get_guid(create=True) - self.comment = CommentFactory(user=user, node=node, target=self.guid) - - -class TestGoogleDriveFileCommentMoveRename(FileCommentMoveRenameTestMixin): - - provider = 'googledrive' - ProviderFile = GoogleDriveFile - -class TestGithubFileCommentMoveRename(FileCommentMoveRenameTestMixin): - - provider = 'github' - ProviderFile = GithubFile - -class TestS3FileCommentMoveRename(FileCommentMoveRenameTestMixin): - - provider = 's3' - ProviderFile = S3File - - -PROVIDER_CLASS = { - 'osfstorage': TestOsfstorageFileCommentMoveRename, - 'box': TestBoxFileCommentMoveRename, - 'dropbox': TestDropboxFileCommentMoveRename, - 'github': TestGithubFileCommentMoveRename, - 'googledrive': TestGoogleDriveFileCommentMoveRename, - 's3': TestS3FileCommentMoveRename - -} diff --git a/osf_tests/test_draft_node.py b/osf_tests/test_draft_node.py index 288fb4c4dd9..29ddbcf4ab8 100644 --- a/osf_tests/test_draft_node.py +++ b/osf_tests/test_draft_node.py @@ -21,6 +21,7 @@ ProjectFactory, get_default_metaschema, ) +from tests.utils import capture_notifications from website.project.signals import after_create_registration from website import settings @@ -119,11 +120,12 @@ def test_draft_node_creation(self, user): def test_create_draft_registration_without_node(self, user): data = {'some': 'data'} - draft = DraftRegistration.create_from_node( - user=user, - schema=get_default_metaschema(), - data=data, - ) + with capture_notifications(): + draft = DraftRegistration.create_from_node( + user=user, + schema=get_default_metaschema(), + data=data, + ) assert draft.title == '' assert draft.branched_from.title == settings.DEFAULT_DRAFT_NODE_TITLE assert draft.branched_from.type == 'osf.draftnode' @@ -145,7 +147,8 @@ def test_register_draft_node(self, user, draft_node, draft_registration): def test_draft_registration_fields_are_copied_back_to_draft_node(self, user, institution, subject, write_contrib, title, description, category, license, make_complex_draft_registration): - draft_registration = make_complex_draft_registration() + with capture_notifications(): + draft_registration = make_complex_draft_registration() draft_node = draft_registration.branched_from with disconnected_from_listeners(after_create_registration): diff --git a/osf_tests/test_draft_registration.py b/osf_tests/test_draft_registration.py index c5b38632230..03691166fa7 100644 --- a/osf_tests/test_draft_registration.py +++ b/osf_tests/test_draft_registration.py @@ -10,6 +10,7 @@ from osf_tests.test_node_license import TestNodeLicenses from django.utils import timezone +from tests.utils import capture_notifications from website import settings from . import factories @@ -249,10 +250,11 @@ def test_create_from_node_existing(self, user): assert draft.branched_from == node def test_create_from_node_draft_node(self, user): - draft = DraftRegistration.create_from_node( - user=user, - schema=factories.get_default_metaschema(), - ) + with capture_notifications(): + draft = DraftRegistration.create_from_node( + user=user, + schema=factories.get_default_metaschema(), + ) assert draft.title == '' assert draft.description == '' @@ -340,13 +342,14 @@ def test_add_contributor(self, draft_registration, user, auth): def test_add_contributors(self, draft_registration, auth): user1 = factories.UserFactory() user2 = factories.UserFactory() - draft_registration.add_contributors( - [ - {'user': user1, 'permissions': ADMIN, 'visible': True}, - {'user': user2, 'permissions': WRITE, 'visible': False} - ], - auth=auth - ) + with capture_notifications(): + draft_registration.add_contributors( + [ + {'user': user1, 'permissions': ADMIN, 'visible': True}, + {'user': user2, 'permissions': WRITE, 'visible': False} + ], + auth=auth + ) last_log = draft_registration.logs.all().order_by('-created')[0] assert ( last_log.params['contributors'] == @@ -378,8 +381,12 @@ def test_cant_add_same_contributor_twice(self, draft_registration): assert len(draft_registration.contributors) == 2 def test_remove_unregistered_conributor_removes_unclaimed_record(self, draft_registration, auth): - new_user = draft_registration.add_unregistered_contributor(fullname='David Davidson', - email='david@davidson.com', auth=auth) + new_user = draft_registration.add_unregistered_contributor( + fullname='David Davidson', + email='david@davidson.com', + auth=auth, + notification_type=False + ) draft_registration.save() assert draft_registration.is_contributor(new_user) # sanity check assert draft_registration._primary_key in new_user.unclaimed_records @@ -410,7 +417,11 @@ def test_visible_initiator(self, project, user): def test_non_visible_initiator(self, project, user): invisible_user = factories.UserFactory() - project.add_contributor(contributor=invisible_user, permissions=ADMIN, visible=False) + project.add_contributor( + contributor=invisible_user, + permissions=ADMIN, + visible=False, + ) invisible_project_contributor = project.contributor_set.get(user=invisible_user) assert invisible_project_contributor.visible is False @@ -493,13 +504,14 @@ def test_remove_contributor(self, draft_registration, auth): def test_remove_contributors(self, draft_registration, auth): user1 = factories.UserFactory() user2 = factories.UserFactory() - draft_registration.add_contributors( - [ - {'user': user1, 'permissions': WRITE, 'visible': True}, - {'user': user2, 'permissions': WRITE, 'visible': True} - ], - auth=auth - ) + with capture_notifications(): + draft_registration.add_contributors( + [ + {'user': user1, 'permissions': WRITE, 'visible': True}, + {'user': user2, 'permissions': WRITE, 'visible': True} + ], + auth=auth + ) assert user1 in draft_registration.contributors assert user2 in draft_registration.contributors assert draft_registration.has_permission(user1, WRITE) diff --git a/osf_tests/test_elastic_search.py b/osf_tests/test_elastic_search.py index 396e0d6b2aa..6060eddf2cb 100644 --- a/osf_tests/test_elastic_search.py +++ b/osf_tests/test_elastic_search.py @@ -27,7 +27,7 @@ from osf_tests import factories from tests.base import OsfTestCase from tests.test_features import requires_search -from tests.utils import run_celery_tasks +from tests.utils import run_celery_tasks, capture_notifications from osf.utils.workflows import CollectionSubmissionStates TEST_INDEX = 'test' @@ -108,8 +108,9 @@ def test_only_public_collections_submissions_are_searchable(self): docs = query_collections('Salif Keita')['results'] assert len(docs) == 0 - self.collection_public.collect_object(self.node_private, self.user) - self.reg_collection.collect_object(self.reg_private, self.user) + with capture_notifications(): + self.collection_public.collect_object(self.node_private, self.user) + self.reg_collection.collect_object(self.reg_private, self.user) docs = query_collections('Salif Keita')['results'] assert len(docs) == 0 @@ -121,9 +122,10 @@ def test_only_public_collections_submissions_are_searchable(self): machine_state=CollectionSubmissionStates.ACCEPTED ).exists() - self.collection_one.collect_object(self.node_one, self.user) - self.collection_public.collect_object(self.node_public, self.user) - self.reg_collection.collect_object(self.reg_public, self.user) + with capture_notifications(): + self.collection_one.collect_object(self.node_one, self.user) + self.collection_public.collect_object(self.node_public, self.user) + self.reg_collection.collect_object(self.reg_public, self.user) assert self.node_one.collection_submissions.filter( machine_state=CollectionSubmissionStates.ACCEPTED @@ -138,8 +140,9 @@ def test_only_public_collections_submissions_are_searchable(self): docs = query_collections('Salif Keita')['results'] assert len(docs) == 3 - self.collection_private.collect_object(self.node_two, self.user) - self.reg_collection_private.collect_object(self.reg_one, self.user) + with capture_notifications(): + self.collection_private.collect_object(self.node_two, self.user) + self.reg_collection_private.collect_object(self.reg_one, self.user) docs = query_collections('Salif Keita')['results'] assert len(docs) == 3 @@ -149,8 +152,9 @@ def test_index_on_submission_privacy_changes(self): docs = query_collections('Salif Keita')['results'] assert len(docs) == 0 - self.collection_public.collect_object(self.node_one, self.user) - self.collection_one.collect_object(self.node_one, self.user) + with capture_notifications(): + self.collection_public.collect_object(self.node_one, self.user) + self.collection_one.collect_object(self.node_one, self.user) docs = query_collections('Salif Keita')['results'] assert len(docs) == 2 @@ -163,7 +167,8 @@ def test_index_on_submission_privacy_changes(self): assert len(docs) == 0 # test_submissions_turned_public_are_added_to_index - self.collection_public.collect_object(self.node_private, self.user) + with capture_notifications(): + self.collection_public.collect_object(self.node_private, self.user) docs = query_collections('Salif Keita')['results'] assert len(docs) == 0 @@ -180,10 +185,11 @@ def test_index_on_collection_privacy_changes(self): docs = query_collections('Salif Keita')['results'] assert len(docs) == 0 - self.collection_public.collect_object(self.node_one, self.user) - self.collection_public.collect_object(self.node_two, self.user) - self.collection_public.collect_object(self.node_public, self.user) - self.reg_collection.collect_object(self.reg_public, self.user) + with capture_notifications(): + self.collection_public.collect_object(self.node_one, self.user) + self.collection_public.collect_object(self.node_two, self.user) + self.collection_public.collect_object(self.node_public, self.user) + self.reg_collection.collect_object(self.reg_public, self.user) docs = query_collections('Salif Keita')['results'] assert len(docs) == 4 @@ -198,10 +204,11 @@ def test_index_on_collection_privacy_changes(self): assert len(docs) == 0 # test_submissions_of_collection_turned_public_are_added_to_index - self.collection_private.collect_object(self.node_one, self.user) - self.collection_private.collect_object(self.node_two, self.user) - self.collection_private.collect_object(self.node_public, self.user) - self.reg_collection_private.collect_object(self.reg_public, self.user) + with capture_notifications(): + self.collection_private.collect_object(self.node_one, self.user) + self.collection_private.collect_object(self.node_two, self.user) + self.collection_private.collect_object(self.node_public, self.user) + self.reg_collection_private.collect_object(self.reg_public, self.user) assert self.node_one.collection_submissions.filter( machine_state=CollectionSubmissionStates.ACCEPTED @@ -232,10 +239,11 @@ def test_collection_submissions_are_removed_from_index_on_delete(self): docs = query_collections('Salif Keita')['results'] assert len(docs) == 0 - self.collection_public.collect_object(self.node_one, self.user) - self.collection_public.collect_object(self.node_two, self.user) - self.collection_public.collect_object(self.node_public, self.user) - self.reg_collection.collect_object(self.reg_public, self.user) + with capture_notifications(): + self.collection_public.collect_object(self.node_one, self.user) + self.collection_public.collect_object(self.node_two, self.user) + self.collection_public.collect_object(self.node_public, self.user) + self.reg_collection.collect_object(self.reg_public, self.user) docs = query_collections('Salif Keita')['results'] assert len(docs) == 4 @@ -249,8 +257,9 @@ def test_collection_submissions_are_removed_from_index_on_delete(self): assert len(docs) == 0 def test_removed_submission_are_removed_from_index(self): - self.collection_public.collect_object(self.node_one, self.user) - self.reg_collection.collect_object(self.reg_public, self.user) + with capture_notifications(): + self.collection_public.collect_object(self.node_one, self.user) + self.reg_collection.collect_object(self.reg_public, self.user) assert self.node_one.collection_submissions.filter( machine_state=CollectionSubmissionStates.ACCEPTED ).exists() @@ -274,7 +283,8 @@ def test_removed_submission_are_removed_from_index(self): assert len(docs) == 0 def test_collection_submission_doc_structure(self): - self.collection_public.collect_object(self.node_one, self.user) + with capture_notifications(): + self.collection_public.collect_object(self.node_one, self.user) docs = query_collections('Keita')['results'] assert docs[0]['_source']['title'] == self.node_one.title with run_celery_tasks(): @@ -291,7 +301,8 @@ def test_collection_submission_doc_structure(self): assert docs[0]['_source']['category'] == 'collectionSubmission' def test_search_updated_after_id_change(self): - self.provider.primary_collection.collect_object(self.node_one, self.node_one.creator) + with capture_notifications(): + self.provider.primary_collection.collect_object(self.node_one, self.node_one.creator) with run_celery_tasks(): self.node_one.save() term = f'provider:{self.provider._id}' @@ -500,7 +511,8 @@ def test_unsubmitted_preprint_primary_file(self): def test_publish_preprint(self): title = 'Date' self.preprint = factories.PreprintFactory(creator=self.user, is_published=False, title=title) - self.preprint.set_published(True, auth=Auth(self.preprint.creator), save=True) + with capture_notifications(): + self.preprint.set_published(True, auth=Auth(self.preprint.creator), save=True) assert self.preprint.title == title docs = query(title)['results'] # Both preprint and primary_file showing up in Elastic @@ -1343,8 +1355,10 @@ def test_migration_collections(self): collection_one = factories.CollectionFactory(is_public=True, provider=provider) collection_two = factories.CollectionFactory(is_public=True, provider=provider) node = factories.NodeFactory(creator=self.user, title='Ali Bomaye', is_public=True) - collection_one.collect_object(node, self.user) - collection_two.collect_object(node, self.user) + + with capture_notifications(): + collection_one.collect_object(node, self.user) + collection_two.collect_object(node, self.user) assert node.collection_submissions.filter( machine_state=CollectionSubmissionStates.ACCEPTED ).exists() diff --git a/osf_tests/test_generate_sitemap.py b/osf_tests/test_generate_sitemap.py index 673183f75ae..848886d3477 100644 --- a/osf_tests/test_generate_sitemap.py +++ b/osf_tests/test_generate_sitemap.py @@ -11,6 +11,7 @@ from scripts import generate_sitemap from osf_tests.factories import (AuthUserFactory, ProjectFactory, RegistrationFactory, CollectionFactory, PreprintFactory, PreprintProviderFactory, EmbargoFactory, UnconfirmedUserFactory) +from tests.utils import capture_notifications from website import settings @@ -106,7 +107,8 @@ def preprint_osf_blank(self, project_preprint_osf, user_admin_project_public, pr @pytest.fixture(autouse=True) def preprint_osf_version(self, preprint_osf_blank): - return PreprintFactory.create_version(create_from=preprint_osf_blank, creator=preprint_osf_blank.creator) + with capture_notifications(): + return PreprintFactory.create_version(create_from=preprint_osf_blank, creator=preprint_osf_blank.creator) @pytest.fixture(autouse=True) def preprint_withdrawn(self, project_preprint_osf, user_admin_project_public, provider_osf): diff --git a/osf_tests/test_guid.py b/osf_tests/test_guid.py index 5a77042e460..0858427b637 100644 --- a/osf_tests/test_guid.py +++ b/osf_tests/test_guid.py @@ -20,6 +20,7 @@ ) from tests.base import OsfTestCase from tests.test_websitefiles import TestFile +from tests.utils import capture_notifications from website.settings import MFR_SERVER_URL, WATERBUTLER_URL @@ -312,9 +313,11 @@ def test_resolve_guid_download_file_from_emberapp_preprints_unpublished(self): # test_provider_submitter_can_download_unpublished submitter = AuthUserFactory() pp = PreprintFactory(finish=True, provider=provider, is_published=False, creator=submitter) - pp.run_submit(submitter) + with capture_notifications(): + pp.run_submit(submitter) pp_branded = PreprintFactory(finish=True, provider=branded_provider, is_published=False, filename='preprint_file_two.txt', creator=submitter) - pp_branded.run_submit(submitter) + with capture_notifications(): + pp_branded.run_submit(submitter) res = self.app.get(f'{pp.url}download', auth=submitter.auth) assert res.status_code == 302 diff --git a/osf_tests/test_institution.py b/osf_tests/test_institution.py index eca6737b6e5..b0575d02fe6 100644 --- a/osf_tests/test_institution.py +++ b/osf_tests/test_institution.py @@ -4,7 +4,7 @@ import pytest from addons.osfstorage.models import Region -from osf.models import Institution, InstitutionStorageRegion +from osf.models import Institution, InstitutionStorageRegion, NotificationType from osf_tests.factories import ( AuthUserFactory, InstitutionFactory, @@ -12,6 +12,7 @@ RegionFactory, UserFactory, ) +from tests.utils import capture_notifications @pytest.mark.django_db @@ -109,7 +110,6 @@ def test_non_group_member_doesnt_have_perms(self, institution, user): @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestInstitutionManager: def test_deactivated_institution_not_in_default_queryset(self): @@ -146,7 +146,7 @@ def test_reactivate_institution(self): institution.reactivate() assert institution.deactivated is None - def test_send_deactivation_email_call_count(self, mock_send_grid): + def test_send_deactivation_email_call_count(self): institution = InstitutionFactory() user_1 = UserFactory() user_1.add_or_update_affiliated_institution(institution) @@ -154,16 +154,21 @@ def test_send_deactivation_email_call_count(self, mock_send_grid): user_2 = UserFactory() user_2.add_or_update_affiliated_institution(institution) user_2.save() - institution._send_deactivation_email() - assert mock_send_grid.call_count == 2 + with capture_notifications() as notifications: + institution._send_deactivation_email() + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_INSTITUTION_DEACTIVATION + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_INSTITUTION_DEACTIVATION - def test_send_deactivation_email_call_args(self, mock_send_grid): + def test_send_deactivation_email_call_args(self): institution = InstitutionFactory() user = UserFactory() user.add_or_update_affiliated_institution(institution) user.save() - institution._send_deactivation_email() - mock_send_grid.assert_called() + with capture_notifications() as notifications: + institution._send_deactivation_email() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_INSTITUTION_DEACTIVATION def test_deactivate_inactive_institution_noop(self): institution = InstitutionFactory() diff --git a/osf_tests/test_institutional_admin_contributors.py b/osf_tests/test_institutional_admin_contributors.py index 93ba0ac1305..8f4c7fad1c1 100644 --- a/osf_tests/test_institutional_admin_contributors.py +++ b/osf_tests/test_institutional_admin_contributors.py @@ -2,7 +2,7 @@ from unittest import mock -from osf.models import Contributor +from osf.models import Contributor, NotificationType from osf_tests.factories import ( AuthUserFactory, ProjectFactory, @@ -142,7 +142,7 @@ def test_requested_permissions_or_default(self, app, project, institutional_admi auth=mock.ANY, permissions=permissions.ADMIN, # `requested_permissions` should take precedence visible=True, - send_email='access_request', + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST, make_curator=False, ) @@ -168,7 +168,7 @@ def test_permissions_override_requested_permissions(self, app, project, institut auth=mock.ANY, permissions=permissions.ADMIN, # `requested_permissions` should take precedence visible=True, - send_email='access_request', + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST, make_curator=False, ) @@ -194,6 +194,6 @@ def test_requested_permissions_is_used(self, app, project, institutional_admin): auth=mock.ANY, permissions=permissions.ADMIN, # `requested_permissions` should take precedence visible=True, - send_email='access_request', + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST, make_curator=False, ) diff --git a/osf_tests/test_merging_users.py b/osf_tests/test_merging_users.py index 0bb124c4f13..2479b8f1c9d 100644 --- a/osf_tests/test_merging_users.py +++ b/osf_tests/test_merging_users.py @@ -6,8 +6,6 @@ from framework.celery_tasks import handlers from website import settings -from website.project.signals import contributor_added -from website.project.views.contributor import notify_added_contributor from website.util.metrics import OsfSourceTags from framework.auth import Auth @@ -139,7 +137,6 @@ def is_mrm_field(value): 'username', 'verification_key', 'verification_key_v2', - 'contributor_added_email_records', 'requested_deactivation', ] @@ -286,12 +283,8 @@ def test_merge_unregistered(self): assert self.user.is_invited is True assert self.user in self.project_with_unreg_contrib.contributors - @mock.patch('website.project.views.contributor.mails.send_mail') - def test_merge_doesnt_send_signal(self, mock_notify): - #Explictly reconnect signal as it is disconnected by default for test - contributor_added.connect(notify_added_contributor) + def test_merge_doesnt_send_signal(self): other_user = UserFactory() with override_flag(ENABLE_GV, active=True): self.user.merge_user(other_user) assert other_user.merged_by._id == self.user._id - assert mock_notify.called is False diff --git a/osf_tests/test_node.py b/osf_tests/test_node.py index ee89ebb2d8a..3dc919f81ff 100644 --- a/osf_tests/test_node.py +++ b/osf_tests/test_node.py @@ -34,7 +34,7 @@ NodeRelation, Registration, DraftRegistration, - CollectionSubmission + CollectionSubmission, NotificationType ) from addons.wiki.models import WikiPage, WikiVersion @@ -42,6 +42,7 @@ from osf.exceptions import ValidationError, ValidationValueError, UserStateError from osf.utils.workflows import DefaultStates, CollectionSubmissionStates from framework.auth.core import Auth +from tests.utils import capture_notifications from osf_tests.factories import ( AuthUserFactory, @@ -120,7 +121,8 @@ def registration(self, project): @pytest.fixture() def template(self, project, auth): - return project.use_as_template(auth=auth) + with capture_notifications(): + return project.use_as_template(auth=auth) @pytest.fixture() def project_with_affiliations(self, user): @@ -377,11 +379,13 @@ def test_recursive_registrations_have_correct_root(self, project, auth): assert reg_grandchild.root == reg_root def test_fork_has_no_parent(self, project, auth): - fork = project.fork_node(auth=auth) + with capture_notifications(): + fork = project.fork_node(auth=auth) assert fork.parent_node is None def test_fork_has_correct_affiliations(self, user, auth, project_with_affiliations): - fork = project_with_affiliations.fork_node(auth=auth) + with capture_notifications(): + fork = project_with_affiliations.fork_node(auth=auth) user_affiliations = user.get_institution_affiliations().values_list('institution__id', flat=True) project_affiliations = project_with_affiliations.affiliated_institutions.values_list('id', flat=True) fork_affiliations = fork.affiliated_institutions.values_list('id', flat=True) @@ -389,12 +393,14 @@ def test_fork_has_correct_affiliations(self, user, auth, project_with_affiliatio assert set(fork_affiliations) == set(user_affiliations) def test_fork_child_has_parent(self, project, auth): - fork = project.fork_node(auth=auth) + with capture_notifications(): + fork = project.fork_node(auth=auth) fork_child = NodeFactory(parent=fork) assert fork_child.parent_node._id == fork._id def test_fork_grandchild_has_child_id(self, project, auth): - fork = project.fork_node(auth=auth) + with capture_notifications(): + fork = project.fork_node(auth=auth) fork_child = NodeFactory(parent=fork) fork_grandchild = NodeFactory(parent=fork_child) assert fork_grandchild.parent_node._id == fork_child._id @@ -403,7 +409,8 @@ def test_recursive_forks_have_correct_root(self, project, auth): child = NodeFactory(parent=project) NodeFactory(parent=child) - fork_root = project.fork_node(auth=auth) + with capture_notifications(): + fork_root = project.fork_node(auth=auth) fork_child = fork_root._nodes.first() fork_grandchild = fork_child._nodes.first() @@ -415,7 +422,8 @@ def test_template_has_no_parent(self, template): assert template.parent_node is None def test_template_has_correct_affiliations(self, user, auth, project_with_affiliations): - template = project_with_affiliations.use_as_template(auth=auth) + with capture_notifications(): + template = project_with_affiliations.use_as_template(auth=auth) user_affiliations = user.get_institution_affiliations().values_list('institution__id', flat=True) project_affiliations = project_with_affiliations.affiliated_institutions.values_list('id', flat=True) template_affiliations = template.affiliated_institutions.values_list('id', flat=True) @@ -435,7 +443,8 @@ def test_recursive_templates_have_correct_root(self, project, auth): child = NodeFactory(parent=project) NodeFactory(parent=child) - template_root = project.use_as_template(auth=auth) + with capture_notifications(): + template_root = project.use_as_template(auth=auth) template_child = template_root._nodes.first() template_grandchild = template_child._nodes.first() @@ -503,32 +512,38 @@ def test_registration_grandchildren_have_correct_root(self, registration): assert registration_grandchild.root._id == registration._id def test_fork_has_own_root(self, project, auth): - fork = project.fork_node(auth=auth) + with capture_notifications(): + fork = project.fork_node(auth=auth) fork.save() assert fork.root._id == fork._id def test_fork_children_have_correct_root(self, project, auth): - fork = project.fork_node(auth=auth) + with capture_notifications(): + fork = project.fork_node(auth=auth) fork_child = NodeFactory(parent=fork) assert fork_child.root._id == fork._id def test_fork_grandchildren_have_correct_root(self, project, auth): - fork = project.fork_node(auth=auth) + with capture_notifications(): + fork = project.fork_node(auth=auth) fork_child = NodeFactory(parent=fork) fork_grandchild = NodeFactory(parent=fork_child) assert fork_grandchild.root._id == fork._id def test_template_project_has_own_root(self, project, auth): - new_project = project.use_as_template(auth=auth) + with capture_notifications(): + new_project = project.use_as_template(auth=auth) assert new_project.root._id == new_project._id def test_template_project_child_has_correct_root(self, project, auth): - new_project = project.use_as_template(auth=auth) + with capture_notifications(): + new_project = project.use_as_template(auth=auth) new_project_child = NodeFactory(parent=new_project) assert new_project_child.root._id == new_project._id def test_template_project_grandchild_has_correct_root(self, project, auth): - new_project = project.use_as_template(auth=auth) + with capture_notifications(): + new_project = project.use_as_template(auth=auth) new_project_child = NodeFactory(parent=new_project) new_project_grandchild = NodeFactory(parent=new_project_child) assert new_project_grandchild.root._id == new_project._id @@ -895,13 +910,14 @@ def test_add_contributor(self, node, user, auth): def test_add_contributors(self, node, auth): user1 = UserFactory() user2 = UserFactory() - node.add_contributors( - [ - {'user': user1, 'permissions': ADMIN, 'visible': True}, - {'user': user2, 'permissions': WRITE, 'visible': False} - ], - auth=auth - ) + with capture_notifications(): + node.add_contributors( + [ + {'user': user1, 'permissions': ADMIN, 'visible': True}, + {'user': user2, 'permissions': WRITE, 'visible': False} + ], + auth=auth + ) last_log = node.logs.all().order_by('-date')[0] assert ( last_log.params['contributors'] == @@ -1092,13 +1108,14 @@ def test_remove_contributor(self, node, auth): def test_remove_contributors(self, node, auth): user1 = UserFactory() user2 = UserFactory() - node.add_contributors( - [ - {'user': user1, 'permissions': permissions.WRITE, 'visible': True}, - {'user': user2, 'permissions': permissions.WRITE, 'visible': True} - ], - auth=auth - ) + with capture_notifications(): + node.add_contributors( + [ + {'user': user1, 'permissions': permissions.WRITE, 'visible': True}, + {'user': user2, 'permissions': permissions.WRITE, 'visible': True} + ], + auth=auth + ) assert user1 in node.contributors assert user2 in node.contributors @@ -1214,7 +1231,8 @@ class TestNodeAddContributorRegisteredOrNot: def test_add_contributor_user_id(self, user, node): registered_user = UserFactory() - contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), user_id=registered_user._id, save=True) + with capture_notifications(): + contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), user_id=registered_user._id) contributor = contributor_obj.user assert contributor in node.contributors assert contributor.is_registered is True @@ -1222,7 +1240,8 @@ def test_add_contributor_user_id(self, user, node): def test_add_contributor_registered_or_not_unreg_user_without_unclaimed_records(self, user, node): unregistered_user = UnregUserFactory() unregistered_user.save() - contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), email=unregistered_user.email, full_name=unregistered_user.fullname) + with capture_notifications(): + contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), email=unregistered_user.email, full_name=unregistered_user.fullname) contributor = contributor_obj.user assert contributor in node.contributors @@ -1231,29 +1250,32 @@ def test_add_contributor_registered_or_not_unreg_user_without_unclaimed_records( def test_add_contributor_user_id_already_contributor(self, user, node): with pytest.raises(ValidationError) as excinfo: - node.add_contributor_registered_or_not(auth=Auth(user), user_id=user._id, save=True) + node.add_contributor_registered_or_not(auth=Auth(user), user_id=user._id) assert 'is already a contributor' in str(excinfo.value) def test_add_contributor_invalid_user_id(self, user, node): with pytest.raises(ValueError) as excinfo: - node.add_contributor_registered_or_not(auth=Auth(user), user_id='abcde', save=True) + node.add_contributor_registered_or_not(auth=Auth(user), user_id='abcde') assert 'was not found' in str(excinfo.value) def test_add_contributor_fullname_email(self, user, node): - contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe', email='jane@doe.com') + with capture_notifications(): + contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe', email='jane@doe.com') contributor = contributor_obj.user assert contributor in node.contributors assert contributor.is_registered is False def test_add_contributor_fullname(self, user, node): - contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe') + with capture_notifications(): + contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe') contributor = contributor_obj.user assert contributor in node.contributors assert contributor.is_registered is False def test_add_contributor_fullname_email_already_exists(self, user, node): registered_user = UserFactory() - contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='F Mercury', email=registered_user.username) + with capture_notifications(): + contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='F Mercury', email=registered_user.username) contributor = contributor_obj.user assert contributor in node.contributors assert contributor.is_registered is True @@ -1262,7 +1284,8 @@ def test_add_contributor_fullname_email_exists_as_secondary(self, user, node): registered_user = UserFactory() secondary_email = 'secondary@test.test' Email.objects.create(address=secondary_email, user=registered_user) - contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='F Mercury', email=secondary_email) + with capture_notifications(): + contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name='F Mercury', email=secondary_email) contributor = contributor_obj.user assert contributor == registered_user assert contributor in node.contributors @@ -1271,7 +1294,8 @@ def test_add_contributor_fullname_email_exists_as_secondary(self, user, node): def test_add_contributor_unregistered(self, user, node): unregistered_user = UnregUserFactory() unregistered_user.save() - contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name=unregistered_user.fullname, email=unregistered_user.email) + with capture_notifications(): + contributor_obj = node.add_contributor_registered_or_not(auth=Auth(user), full_name=unregistered_user.fullname, email=unregistered_user.email) contributor = contributor_obj.user assert contributor == unregistered_user assert contributor in node.contributors @@ -1321,7 +1345,8 @@ def test_add_contributors_sends_contributor_added_signal(self, node, auth): 'permissions': permissions.WRITE }] with capture_signals() as mock_signals: - node.add_contributors(contributors=contributors, auth=auth) + with capture_notifications(): + node.add_contributors(contributors=contributors, auth=auth) node.save() assert node.is_contributor(user) assert mock_signals.signals_sent() == {contributor_added} @@ -1653,14 +1678,16 @@ def test_can_view_public(self, project, auth): assert project.can_view(other_guy_auth) def test_is_fork_of(self, project): - fork1 = project.fork_node(auth=Auth(user=project.creator)) - fork2 = fork1.fork_node(auth=Auth(user=project.creator)) + with capture_notifications(): + fork1 = project.fork_node(auth=Auth(user=project.creator)) + fork2 = fork1.fork_node(auth=Auth(user=project.creator)) assert fork1.is_fork_of(project) is True assert fork2.is_fork_of(project) is True def test_is_fork_of_false(self, project): to_fork = ProjectFactory() - fork = to_fork.fork_node(auth=Auth(user=to_fork.creator)) + with capture_notifications(): + fork = to_fork.fork_node(auth=Auth(user=to_fork.creator)) assert fork.is_fork_of(project) is False def test_is_fork_of_no_forked_from(self, project): @@ -1707,7 +1734,8 @@ def test_is_contributor_unregistered(self, project, auth): project.add_unregistered_contributor( fullname='David Davidson', email=unreg.username, - auth=auth + auth=auth, + notification_type=False ) project.save() assert project.is_contributor(unreg) is True @@ -2049,7 +2077,8 @@ def test_find_by_institutions(): user = project.creator user.add_or_update_affiliated_institution(inst1) user.add_or_update_affiliated_institution(inst2) - project.add_affiliated_institution(inst1, user=user) + with capture_notifications(): + project.add_affiliated_institution(inst1, user=user) project.save() inst1_result = Node.find_by_institutions(inst1) @@ -2091,7 +2120,8 @@ def test_set_privacy_checks_admin_permissions(self, user): with pytest.raises(PermissionsError): project.set_privacy('public', Auth(non_contrib)) - project.set_privacy('public', Auth(project.creator)) + with capture_notifications(): + project.set_privacy('public', Auth(project.creator)) project.save() # Non-contrib can't make project private @@ -2116,7 +2146,8 @@ def test_set_privacy_pending_registration(self, user): def test_set_privacy(self, node, auth): last_logged_before_method_call = node.last_logged - node.set_privacy('public', auth=auth) + with capture_notifications(): + node.set_privacy('public', auth=auth) assert node.logs.first().action == NodeLog.MADE_PUBLIC assert last_logged_before_method_call != node.last_logged node.save() @@ -2127,23 +2158,17 @@ def test_set_privacy(self, node, auth): assert node.logs.first().action == NodeLog.MADE_PRIVATE assert last_logged_before_method_call != node.last_logged - @mock.patch('osf.models.queued_mail.queue_mail') - def test_set_privacy_sends_mail_default(self, mock_queue, node, auth): - node.set_privacy('private', auth=auth) - node.set_privacy('public', auth=auth) - assert mock_queue.call_count == 1 + def test_set_privacy_sends_mail_default(self, node, auth): + with capture_notifications(): + node.set_privacy('private', auth=auth) + node.set_privacy('public', auth=auth) - @mock.patch('osf.models.queued_mail.queue_mail') - def test_set_privacy_sends_mail(self, mock_queue, node, auth): - node.set_privacy('private', auth=auth) - node.set_privacy('public', auth=auth, meeting_creation=False) - assert mock_queue.call_count == 1 - - @mock.patch('osf.models.queued_mail.queue_mail') - def test_set_privacy_skips_mail_if_meeting(self, mock_queue, node, auth): - node.set_privacy('private', auth=auth) - node.set_privacy('public', auth=auth, meeting_creation=True) - assert bool(mock_queue.called) is False + def test_set_privacy_sends_mail(self, node, auth): + with capture_notifications() as notifications: + node.set_privacy('private', auth=auth) + node.set_privacy('public', auth=auth, meeting_creation=False) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_NEW_PUBLIC_PROJECT def test_set_privacy_can_not_cancel_pending_embargo_for_registration(self, node, user, auth): registration = RegistrationFactory(project=node) @@ -2223,7 +2248,8 @@ def test_do_check_spam_called_on_set_public(self, mock_get_request, project, use with mock.patch.object(Node, 'do_check_spam') as mock_do_check_spam: mock_do_check_spam.return_value = False - project.set_privacy('public', auth=Auth(user)) + with capture_notifications(): + project.set_privacy('public', auth=Auth(user)) mock_do_check_spam.assert_called_once() args = mock_do_check_spam.call_args[0] @@ -2579,12 +2605,13 @@ def test_contributor_manage_visibility(self, node, user, auth): def test_contributor_set_visibility_validation(self, node, user, auth): reg_user1, reg_user2 = UserFactory(), UserFactory() - node.add_contributors( - [ - {'user': reg_user1, 'permissions': ADMIN, 'visible': True}, - {'user': reg_user2, 'permissions': ADMIN, 'visible': False}, - ] - ) + with capture_notifications(): + node.add_contributors( + [ + {'user': reg_user1, 'permissions': ADMIN, 'visible': True}, + {'user': reg_user2, 'permissions': ADMIN, 'visible': False}, + ] + ) with pytest.raises(ValueError) as e: node.set_visible(user=reg_user1, visible=False, auth=None) node.set_visible(user=user, visible=False, auth=None) @@ -3012,13 +3039,15 @@ def test_fork_pointer_not_present(self, node, auth): def test_cannot_fork_deleted_node(self, node, auth): child = NodeFactory(parent=node, is_deleted=True) child.save() - fork = node.fork_node(auth=auth) + with capture_notifications(): + fork = node.fork_node(auth=auth) assert not fork.nodes def test_cannot_template_deleted_node(self, node, auth): child = NodeFactory(parent=node, is_deleted=True) child.save() - template = node.use_as_template(auth=auth, top_level=False) + with capture_notifications(): + template = node.use_as_template(auth=auth, top_level=False) assert not template.nodes def _fork_pointer(self, node, content, auth): @@ -3045,11 +3074,13 @@ def _fork_pointer(self, node, content, auth): def test_fork_pointer_project(self, node, user, auth): project = ProjectFactory(creator=user) - self._fork_pointer(node=node, content=project, auth=auth) + with capture_notifications(): + self._fork_pointer(node=node, content=project, auth=auth) def test_fork_pointer_component(self, node, user, auth): component = NodeFactory(creator=user) - self._fork_pointer(node=node, content=component, auth=auth) + with capture_notifications(): + self._fork_pointer(node=node, content=component, auth=auth) # copied from tests/test_models.py @@ -3133,8 +3164,9 @@ def test_fork_recursion(self, mock_push_status_message, project, user, subject, fork_date = timezone.now() # Fork node - with mock.patch.object(Node, 'bulk_update_search'): - fork = project.fork_node(auth=auth) + with capture_notifications(): + with mock.patch.object(Node, 'bulk_update_search'): + fork = project.fork_node(auth=auth) # Compare fork to original self._cmp_fork_original(user, fork_date, fork, project) @@ -3188,19 +3220,22 @@ def test_fork_private_children(self, node, user, auth): user2_auth = Auth(user=user2) fork = None # New user forks the project - fork = node.fork_node(user2_auth) + with capture_notifications(): + fork = node.fork_node(user2_auth) # fork correct children assert fork._nodes.count() == 2 assert 'Not Forked' not in fork._nodes.values_list('title', flat=True) def test_fork_not_public(self, node, auth): - node.set_privacy('public') - fork = node.fork_node(auth) + with capture_notifications(): + node.set_privacy('public') + fork = node.fork_node(auth) assert fork.is_public is False def test_fork_log_has_correct_log(self, node, auth): - fork = node.fork_node(auth) + with capture_notifications(): + fork = node.fork_node(auth) last_log = fork.logs.latest() assert last_log.action == NodeLog.NODE_FORKED # Legacy 'registration' param should be the ID of the fork @@ -3212,7 +3247,8 @@ def test_not_fork_private_link(self, node, auth): link = PrivateLinkFactory() link.nodes.add(node) link.save() - fork = node.fork_node(auth) + with capture_notifications(): + fork = node.fork_node(auth) assert link not in fork.private_links.all() def test_cannot_fork_private_node(self, node): @@ -3225,14 +3261,16 @@ def test_can_fork_public_node(self, node): node.set_privacy('public') user2 = UserFactory() user2_auth = Auth(user=user2) - fork = node.fork_node(user2_auth) + with capture_notifications(): + fork = node.fork_node(user2_auth) assert bool(fork) is True def test_contributor_can_fork(self, node): user2 = UserFactory() node.add_contributor(user2) user2_auth = Auth(user=user2) - fork = node.fork_node(user2_auth) + with capture_notifications(): + fork = node.fork_node(user2_auth) assert bool(fork) is True # Forker has admin permissions assert fork.contributors.count() == 1 @@ -3242,12 +3280,14 @@ def test_fork_preserves_license(self, node, auth): license = NodeLicenseRecordFactory() node.node_license = license node.save() - fork = node.fork_node(auth) + with capture_notifications(): + fork = node.fork_node(auth) assert fork.node_license.license_id == license.license_id def test_fork_registration(self, user, node, auth): registration = RegistrationFactory(project=node) - fork = registration.fork_node(auth) + with capture_notifications(): + fork = registration.fork_node(auth) # fork should not be a registration assert fork.is_registration is False @@ -3262,7 +3302,8 @@ def test_fork_registration(self, user, node, auth): def test_fork_project_with_no_wiki_pages(self, user, auth): project = ProjectFactory(creator=user) - fork = project.fork_node(auth) + with capture_notifications(): + fork = project.fork_node(auth) assert WikiPage.objects.get_wiki_pages_latest(fork).exists() is False assert fork.wikis.all().exists() is False assert fork.wiki_private_uuids == {} @@ -3279,7 +3320,8 @@ def test_forking_clones_project_wiki_pages(self, user, auth): wiki_page=wiki_page, ) current_wiki = WikiVersionFactory(wiki_page=wiki_page, identifier=2) - fork = project.fork_node(auth) + with capture_notifications(): + fork = project.fork_node(auth) assert fork.wiki_private_uuids == {} fork_wiki_current = WikiVersion.objects.get_for_node(fork, current_wiki.wiki_page.page_name) @@ -3314,13 +3356,14 @@ def test_can_set_contributor_order(self, node): def test_move_contributor(self, user, node, auth): user1 = UserFactory() user2 = UserFactory() - node.add_contributors( - [ - {'user': user1, 'permissions': WRITE, 'visible': True}, - {'user': user2, 'permissions': WRITE, 'visible': True} - ], - auth=auth - ) + with capture_notifications(): + node.add_contributors( + [ + {'user': user1, 'permissions': WRITE, 'visible': True}, + {'user': user2, 'permissions': WRITE, 'visible': True} + ], + auth=auth + ) user_contrib_id = node.contributor_set.get(user=user).id user1_contrib_id = node.contributor_set.get(user=user1).id @@ -3706,7 +3749,8 @@ def test_category_display(self): assert node2.category_display == 'Methods and Measures' def test_update_is_public(self, node, user, auth): - node.update({'is_public': True}, auth=auth, save=True) + with capture_notifications(): + node.update({'is_public': True}, auth=auth, save=True) assert node.is_public last_log = node.logs.latest() @@ -3779,11 +3823,12 @@ def collection(self): @pytest.fixture() def node_in_collection(self, collection): node = ProjectFactory(is_public=True) - CollectionSubmission( - guid=node.guids.first(), - collection=collection, - creator=node.creator, - ).save() + with capture_notifications(): + CollectionSubmission( + guid=node.guids.first(), + collection=collection, + creator=node.creator, + ).save() return node @pytest.fixture() @@ -3956,9 +4001,10 @@ def _verify_log(self, node): def test_simple_template(self, project, auth): """Create a templated node, with no changes""" # created templated node - new = project.use_as_template( - auth=auth - ) + with capture_notifications(): + new = project.use_as_template( + auth=auth + ) assert new.title == self._default_title(project) assert new.created != project.created @@ -3969,23 +4015,25 @@ def test_simple_template_title_changed(self, project, auth): changed_title = 'Made from template' # create templated node - new = project.use_as_template( - auth=auth, - changes={ - project._primary_key: { - 'title': changed_title, + with capture_notifications(): + new = project.use_as_template( + auth=auth, + changes={ + project._primary_key: { + 'title': changed_title, + } } - } - ) + ) assert new.title == changed_title assert new.created != project.created self._verify_log(new) def test_use_as_template_adds_default_addons(self, project, auth): - new = project.use_as_template( - auth=auth - ) + with capture_notifications(): + new = project.use_as_template( + auth=auth + ) assert new.has_addon('wiki') assert new.has_addon('osfstorage') @@ -3994,21 +4042,24 @@ def test_use_as_template_preserves_license(self, project, auth): license = NodeLicenseRecordFactory() project.node_license = license project.save() - new = project.use_as_template( - auth=auth - ) + with capture_notifications(): + new = project.use_as_template( + auth=auth + ) assert new.license.node_license._id == license.node_license._id self._verify_log(new) def test_can_template_a_registration(self, user, auth): registration = RegistrationFactory(creator=user) - new = registration.use_as_template(auth=auth) + with capture_notifications(): + new = registration.use_as_template(auth=auth) assert new.is_registration is False def test_cannot_template_deleted_registration(self, project, auth): registration = RegistrationFactory(project=project, is_deleted=True) - new = registration.use_as_template(auth=auth) + with capture_notifications(): + new = registration.use_as_template(auth=auth) assert not new.nodes @pytest.fixture() @@ -4038,7 +4089,8 @@ def test_complex_template_without_pointee(self, auth, user): project1 = ProjectFactory(creator=user) ProjectFactory(creator=user, parent=project1) - new = project1.use_as_template(auth=auth) + with capture_notifications(): + new = project1.use_as_template(auth=auth) assert new.title == self._default_title(project1) assert len(list(new.nodes)) == len(list(project1.nodes)) @@ -4056,7 +4108,8 @@ def test_complex_template_with_pointee(self, auth, project, pointee, component, """Create a templated node from a node with children""" # create templated node - new = project.use_as_template(auth=auth) + with capture_notifications(): + new = project.use_as_template(auth=auth) assert new.title == self._default_title(project) assert len(list(new.nodes)) == len(list(project.nodes)) - 1 @@ -4080,10 +4133,11 @@ def test_complex_template_titles_changed(self, auth, project, pointee, component } # create templated node - new = project.use_as_template( - auth=auth, - changes=changes - ) + with capture_notifications(): + new = project.use_as_template( + auth=auth, + changes=changes + ) old_nodes = [x for x in project.nodes if x not in project.linked_nodes] for old_node, new_node in zip(old_nodes, new.nodes): @@ -4100,9 +4154,10 @@ def test_complex_template_titles_changed(self, auth, project, pointee, component def test_template_wiki_pages_not_copied(self, project, auth): WikiPage.objects.create_for_node(project, 'template', 'lol', auth) - new = project.use_as_template( - auth=auth - ) + with capture_notifications(): + new = project.use_as_template( + auth=auth + ) assert WikiPage.objects.get_for_node(project, 'template').page_name == 'template' latest_version = WikiVersion.objects.get_for_node(project, 'template') assert latest_version.identifier == 1 @@ -4116,7 +4171,8 @@ def test_user_who_makes_node_from_template_has_creator_permission(self): user = UserFactory() auth = Auth(user) - templated = project.use_as_template(auth) + with capture_notifications(): + templated = project.use_as_template(auth) assert set(templated.get_permissions(user)) == {permissions.READ, permissions.WRITE, permissions.ADMIN} @@ -4151,7 +4207,8 @@ def test_template_security(self, user, auth, project, pointee, component, subpro visible_nodes = [x for x in project.nodes if x.can_view(other_user_auth)] # create templated node - new = project.use_as_template(auth=other_user_auth) + with capture_notifications(): + new = project.use_as_template(auth=other_user_auth) assert new.title == self._default_title(project) @@ -4210,7 +4267,8 @@ def test_original_node_and_current_node_for_registration_logs(self): def test_original_node_and_current_node_for_fork_logs(self): user = UserFactory() project = ProjectFactory(creator=user) - fork = project.fork_node(auth=Auth(user)) + with capture_notifications(): + fork = project.fork_node(auth=Auth(user)) log_project_created_original = project.logs.last() log_project_created_fork = fork.logs.last() @@ -4370,7 +4428,8 @@ def test_remove_contributor_callback(self, node, auth): ) def test_set_privacy_callback(self, node, auth): - node.set_privacy('public', auth) + with capture_notifications(): + node.set_privacy('public', auth) for addon in node.addons: callback = addon.after_set_privacy callback.assert_called_with( @@ -4385,12 +4444,13 @@ def test_set_privacy_callback(self, node, auth): ) def test_fork_callback(self, node, auth): - fork = node.fork_node(auth=auth) - for addon in node.addons: - callback = addon.after_fork - callback.assert_called_once_with( - node, fork, auth.user - ) + with capture_notifications(): + fork = node.fork_node(auth=auth) + for addon in node.addons: + callback = addon.after_fork + callback.assert_called_once_with( + node, fork, auth.user + ) def test_register_callback(self, node, auth): with mock_archive(node) as registration: @@ -4588,13 +4648,13 @@ def test_collection_project_views( assert not node.collection_submissions.filter( machine_state=CollectionSubmissionStates.ACCEPTED ).exists() - - collection_one.collect_object(node, collector) - collection_two.collect_object(node, collector) - public_non_provided_collection.collect_object(node, collector) - private_non_provided_collection.collect_object(node, collector) - bookmark_collection.collect_object(node, collector) - collection_public.collect_object(node, collector) + with capture_notifications(): + collection_one.collect_object(node, collector) + collection_two.collect_object(node, collector) + public_non_provided_collection.collect_object(node, collector) + private_non_provided_collection.collect_object(node, collector) + bookmark_collection.collect_object(node, collector) + collection_public.collect_object(node, collector) assert node.collection_submissions.filter( machine_state=CollectionSubmissionStates.ACCEPTED @@ -4612,14 +4672,14 @@ def test_permissions_collection_project_views( self, user, node, contrib, subjects, collection_one, collection_two, collection_public, public_non_provided_collection, private_non_provided_collection, bookmark_collection, collector): - - collection_one.collect_object(node, collector) - collection_two.collect_object(node, collector) - public_non_provided_collection.collect_object(node, collector) - private_non_provided_collection.collect_object(node, collector) - bookmark_collection.collect_object(node, collector) - collection_submission = collection_public.collect_object(node, collector, status='Complete', collected_type='Dataset') - collection_submission.set_subjects(subjects, Auth(collector)) + with capture_notifications(): + collection_one.collect_object(node, collector) + collection_two.collect_object(node, collector) + public_non_provided_collection.collect_object(node, collector) + private_non_provided_collection.collect_object(node, collector) + bookmark_collection.collect_object(node, collector) + collection_submission = collection_public.collect_object(node, collector, status='Complete', collected_type='Dataset') + collection_submission.set_subjects(subjects, Auth(collector)) ## test_not_logged_in_user_only_sees_public_collection_info collection_summary = serialize_collections(node.collection_submissions, Auth()) diff --git a/osf_tests/test_preprint_factory.py b/osf_tests/test_preprint_factory.py index 591cd2550a9..1c5e8a91848 100644 --- a/osf_tests/test_preprint_factory.py +++ b/osf_tests/test_preprint_factory.py @@ -3,6 +3,7 @@ from osf_tests.factories import PreprintFactory, AuthUserFactory, PreprintProviderFactory from osf.models import Preprint from framework.auth import Auth +from tests.utils import capture_notifications fake = Faker() @@ -96,7 +97,8 @@ def test_create_version_increments_version_number(self): assert original_guid is not None assert original_guid.versions.count() == 1 - new_preprint = PreprintFactory.create_version(create_from=original_preprint) + with capture_notifications(): + new_preprint = PreprintFactory.create_version(create_from=original_preprint) assert new_preprint is not None assert original_guid.versions.count() == 2 @@ -109,7 +111,8 @@ def test_create_version_copies_fields(self): description = 'Original description.' original_preprint = PreprintFactory(title=title, description=description) - new_preprint = PreprintFactory.create_version(create_from=original_preprint) + with capture_notifications(): + new_preprint = PreprintFactory.create_version(create_from=original_preprint) assert new_preprint.title == title assert new_preprint.description == description @@ -120,7 +123,8 @@ def test_create_version_copies_subjects(self): original_preprint = PreprintFactory() original_subjects = [[subject._id] for subject in original_preprint.subjects.all()] - new_preprint = PreprintFactory.create_version(create_from=original_preprint) + with capture_notifications(): + new_preprint = PreprintFactory.create_version(create_from=original_preprint) new_subjects = [[subject._id] for subject in new_preprint.subjects.all()] assert original_subjects == new_subjects @@ -131,7 +135,8 @@ def test_create_version_copies_contributors(self): original_preprint.contributor_set.exclude(user=original_preprint.creator).values_list('user_id', flat=True) ) - new_preprint = PreprintFactory.create_version(create_from=original_preprint) + with capture_notifications(): + new_preprint = PreprintFactory.create_version(create_from=original_preprint) contributors_after = list( new_preprint.contributor_set.exclude(user=new_preprint.creator).values_list('user_id', flat=True) @@ -140,18 +145,20 @@ def test_create_version_copies_contributors(self): def test_create_version_with_machine_state(self): original_preprint = PreprintFactory() - new_preprint = PreprintFactory.create_version( - create_from=original_preprint, final_machine_state='accepted' - ) + with capture_notifications(): + new_preprint = PreprintFactory.create_version( + create_from=original_preprint, final_machine_state='accepted' + ) assert new_preprint.machine_state == 'accepted' def test_create_version_published_flag(self): original_preprint = PreprintFactory(is_published=True) original_guid = original_preprint.guids.first() - new_preprint = PreprintFactory.create_version( - create_from=original_preprint, is_published=True - ) + with capture_notifications(): + new_preprint = PreprintFactory.create_version( + create_from=original_preprint, is_published=True + ) original_guid.refresh_from_db() assert new_preprint.is_published is True assert original_guid.referent == new_preprint @@ -159,7 +166,10 @@ def test_create_version_published_flag(self): def test_create_version_unpublished(self): original_preprint = PreprintFactory(is_published=True) new_preprint = PreprintFactory.create_version( - create_from=original_preprint, is_published=False, set_doi=False, final_machine_state='pending' + create_from=original_preprint, + is_published=False, + set_doi=False, + final_machine_state='pending' ) assert new_preprint.is_published is False assert new_preprint.machine_state == 'pending' diff --git a/osf_tests/test_queued_mail.py b/osf_tests/test_queued_mail.py deleted file mode 100644 index 395b770a61d..00000000000 --- a/osf_tests/test_queued_mail.py +++ /dev/null @@ -1,155 +0,0 @@ -# Ported from tests.test_mails -import datetime as dt - - -import pytest -from django.utils import timezone -from waffle.testutils import override_switch - -from .factories import UserFactory, NodeFactory - -from osf.features import DISABLE_ENGAGEMENT_EMAILS -from osf.models.queued_mail import ( - queue_mail, WELCOME_OSF4M, - NO_LOGIN, NO_ADDON, NEW_PUBLIC_PROJECT -) -from website.mails import mails -from website.settings import DOMAIN - -@pytest.fixture() -def user(): - return UserFactory(is_registered=True) - -@pytest.mark.django_db -class TestQueuedMail: - - def queue_mail(self, mail, user, send_at=None, **kwargs): - mail = queue_mail( - to_addr=user.username if user else user.username, - send_at=send_at or timezone.now(), - user=user, - mail=mail, - fullname=user.fullname if user else user.username, - **kwargs - ) - return mail - - def test_no_login_presend_for_active_user(self, user): - mail = self.queue_mail(mail=NO_LOGIN, user=user) - user.date_last_login = timezone.now() + dt.timedelta(seconds=10) - user.save() - assert mail.send_mail() is False - - def test_no_login_presend_for_inactive_user(self, user): - mail = self.queue_mail(mail=NO_LOGIN, user=user) - user.date_last_login = timezone.now() - dt.timedelta(weeks=10) - user.save() - assert timezone.now() - dt.timedelta(days=1) > user.date_last_login - assert bool(mail.send_mail()) is True - - def test_no_addon_presend(self, user): - mail = self.queue_mail(mail=NO_ADDON, user=user) - assert mail.send_mail() is True - - def test_new_public_project_presend_for_no_project(self, user): - mail = self.queue_mail( - mail=NEW_PUBLIC_PROJECT, - user=user, - project_title='Oh noes', - nid='', - ) - assert bool(mail.send_mail()) is False - - def test_new_public_project_presend_success(self, user): - node = NodeFactory(is_public=True) - mail = self.queue_mail( - mail=NEW_PUBLIC_PROJECT, - user=user, - project_title='Oh yass', - nid=node._id - ) - assert bool(mail.send_mail()) is True - - def test_welcome_osf4m_presend(self, user): - user.date_last_login = timezone.now() - dt.timedelta(days=13) - user.save() - mail = self.queue_mail( - mail=WELCOME_OSF4M, - user=user, - conference='Buttjamz conference', - fid='', - domain=DOMAIN - ) - assert bool(mail.send_mail()) is True - assert mail.data['downloads'] == 0 - - def test_finding_other_emails_sent_to_user(self, user): - mail = self.queue_mail( - user=user, - mail=NO_ADDON, - ) - assert len(mail.find_sent_of_same_type_and_user()) == 0 - mail.send_mail() - assert len(mail.find_sent_of_same_type_and_user()) == 1 - - def test_user_is_active(self, user): - mail = self.queue_mail( - user=user, - mail=NO_ADDON, - ) - assert bool(mail.send_mail()) is True - - def test_user_is_not_active_no_password(self): - user = UserFactory.build() - user.set_unusable_password() - user.save() - mail = self.queue_mail( - user=user, - mail=NO_ADDON, - ) - assert mail.send_mail() is False - - def test_user_is_not_active_not_registered(self): - user = UserFactory(is_registered=False) - mail = self.queue_mail( - user=user, - mail=NO_ADDON, - ) - assert mail.send_mail() is False - - def test_user_is_not_active_is_merged(self): - other_user = UserFactory() - user = UserFactory(merged_by=other_user) - mail = self.queue_mail( - user=user, - mail=NO_ADDON, - ) - assert mail.send_mail() is False - - def test_user_is_not_active_is_disabled(self): - user = UserFactory(date_disabled=timezone.now()) - mail = self.queue_mail( - user=user, - mail=NO_ADDON, - ) - assert mail.send_mail() is False - - def test_user_is_not_active_is_not_confirmed(self): - user = UserFactory(date_confirmed=None) - mail = self.queue_mail( - user=user, - mail=NO_ADDON, - ) - assert mail.send_mail() is False - - def test_disabled_queued_emails_not_sent_if_switch_active(self, user): - with override_switch(DISABLE_ENGAGEMENT_EMAILS, active=True): - assert self.queue_mail(mail=NO_ADDON, user=user) is False - assert self.queue_mail(mail=NO_LOGIN, user=user) is False - assert self.queue_mail(mail=WELCOME_OSF4M, user=user) is False - assert self.queue_mail(mail=NEW_PUBLIC_PROJECT, user=user) is False - - def test_disabled_triggered_emails_not_sent_if_switch_active(self): - with override_switch(DISABLE_ENGAGEMENT_EMAILS, active=True): - assert mails.send_mail(to_addr='', mail=mails.WELCOME) is False - assert mails.send_mail(to_addr='', mail=mails.WELCOME_OSF4I) is False diff --git a/osf_tests/test_registration_moderation_notifications.py b/osf_tests/test_registration_moderation_notifications.py index 100c15e64e1..943c5c3e32c 100644 --- a/osf_tests/test_registration_moderation_notifications.py +++ b/osf_tests/test_registration_moderation_notifications.py @@ -1,17 +1,15 @@ import pytest from unittest import mock -from unittest.mock import call from django.utils import timezone -from osf.management.commands.add_notification_subscription import add_reviews_notification_setting -from osf.management.commands.populate_registration_provider_notification_subscriptions import populate_registration_provider_notification_subscriptions +from notifications.tasks import send_users_digest_email, send_moderators_digest_email +from osf.management.commands.populate_notification_types import populate_notification_types from osf.migrations import update_provider_auth_groups -from osf.models import Brand, NotificationDigest +from osf.models import Brand, NotificationSubscription, NotificationType from osf.models.action import RegistrationAction from osf.utils.notifications import ( notify_submit, - notify_accept_reject, notify_moderator_registration_requests_withdrawal, notify_reject_withdraw_request, notify_withdraw_registration @@ -23,10 +21,7 @@ AuthUserFactory, RetractionFactory ) - -from website import settings -from website.notifications import emails, tasks - +from tests.utils import capture_notifications def get_moderator(provider): user = AuthUserFactory() @@ -38,21 +33,19 @@ def get_daily_moderator(provider): user = AuthUserFactory() provider.add_to_group(user, 'moderator') for subscription_type in provider.DEFAULT_SUBSCRIPTIONS: - subscription = provider.notification_subscriptions.get(event_name=subscription_type) - subscription.add_user_to_subscription(user, 'email_digest') + provider.notification_subscriptions.get(event_name=subscription_type) return user # Set USE_EMAIL to true and mock out the default mailer for consistency with other mocked settings @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestRegistrationMachineNotification: MOCK_NOW = timezone.now() @pytest.fixture(autouse=True) def setup(self): - populate_registration_provider_notification_subscriptions() + populate_notification_types() with mock.patch('osf.utils.machines.timezone.now', return_value=self.MOCK_NOW): yield @@ -96,9 +89,6 @@ def moderator(self, provider): def daily_moderator(self, provider): user = AuthUserFactory() provider.add_to_group(user, 'moderator') - for subscription_type in provider.DEFAULT_SUBSCRIPTIONS: - subscription = provider.notification_subscriptions.get(event_name=subscription_type) - subscription.add_user_to_subscription(user, 'email_digest') return user @pytest.fixture() @@ -137,321 +127,74 @@ def withdraw_action(self, registration, admin): ) return registration_action - def test_submit_notifications(self, registration, moderator, admin, contrib, provider, mock_send_grid): + def test_submit_notifications(self, registration, moderator, admin, contrib, provider): """ [REQS-96] "As moderator of branded registry, I receive email notification upon admin author(s) submission approval" - :param mock_email: - :param draft_registration: - :return: """ - # Set up mock_send_mail as a pass-through to the original function. - # This lets us assert on the call/args and also implicitly ensures - # that the email acutally renders as normal in send_mail. - notify_submit(registration, admin) - - assert len(mock_send_grid.call_args_list) == 2 - admin_message, contrib_message = mock_send_grid.call_args_list - - assert admin_message[1]['to_addr'] == admin.email - assert contrib_message[1]['to_addr'] == contrib.email - assert admin_message[1]['subject'] == 'Confirmation of your submission to OSF Registries' - assert contrib_message[1]['subject'] == 'Confirmation of your submission to OSF Registries' - - assert NotificationDigest.objects.count() == 1 - digest = NotificationDigest.objects.last() - + with capture_notifications() as notification: + notify_submit(registration, admin) + + assert len(notification['emits']) == 4 + assert notification['emits'][0]['type'] == NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION + assert notification['emits'][0]['kwargs']['user'] == admin + assert notification['emits'][1]['type'] == NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION + assert notification['emits'][1]['kwargs']['user'] == contrib + assert notification['emits'][2]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + assert notification['emits'][3]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + + assert NotificationSubscription.objects.count() == 5 + digest = NotificationSubscription.objects.last() assert digest.user == moderator - assert digest.send_type == 'email_transactional' - assert digest.event == 'new_pending_submissions' - - def test_accept_notifications(self, registration, moderator, admin, contrib, accept_action): - """ - [REQS-98] "As registration authors, we receive email notification upon moderator acceptance" - :param draft_registration: - :return: - """ - add_reviews_notification_setting('global_reviews') - - # Set up mock_email as a pass-through to the original function. - # This lets us assert on the call count/args and also implicitly - # ensures that the email acutally renders correctly. - store_emails = emails.store_emails - with mock.patch.object(emails, 'store_emails', side_effect=store_emails) as mock_email: - notify_accept_reject(registration, registration.creator, accept_action, RegistrationModerationStates) - - assert len(mock_email.call_args_list) == 2 - - admin_message, contrib_message = mock_email.call_args_list - - assert admin_message == call( - [admin._id], - 'email_transactional', - 'global_reviews', - admin, - registration, - self.MOCK_NOW, - comment='yo', - document_type='registration', - domain='http://localhost:5000/', - draft_registration=registration.draft_registration.get(), - has_psyarxiv_chronos_text=False, - is_creator=True, - is_rejected=False, - notify_comment='yo', - provider_contact_email=settings.OSF_CONTACT_EMAIL, - provider_support_email=settings.OSF_SUPPORT_EMAIL, - provider_url='http://localhost:5000/', - requester=admin, - reviewable=registration, - template='reviews_submission_status', - was_pending=False, - workflow=None - ) - - assert contrib_message == call( - [contrib._id], - 'email_transactional', - 'global_reviews', - admin, - registration, - self.MOCK_NOW, - comment='yo', - document_type='registration', - domain='http://localhost:5000/', - draft_registration=registration.draft_registration.get(), - has_psyarxiv_chronos_text=False, - is_creator=False, - is_rejected=False, - notify_comment='yo', - provider_contact_email=settings.OSF_CONTACT_EMAIL, - provider_support_email=settings.OSF_SUPPORT_EMAIL, - provider_url='http://localhost:5000/', - reviewable=registration, - requester=admin, - template='reviews_submission_status', - was_pending=False, - workflow=None - ) - - def test_reject_notifications(self, registration, moderator, admin, contrib, accept_action): - """ - [REQS-100] "As authors of rejected by moderator registration, we receive email notification of registration returned - to draft state" - :param draft_registration: - :return: - """ - add_reviews_notification_setting('global_reviews') - - # Set up mock_email as a pass-through to the original function. - # This lets us assert on the call count/args and also implicitly - # ensures that the email acutally renders correctly - store_emails = emails.store_emails - with mock.patch.object(emails, 'store_emails', side_effect=store_emails) as mock_email: - notify_accept_reject(registration, registration.creator, accept_action, RegistrationModerationStates) - - assert len(mock_email.call_args_list) == 2 - - admin_message, contrib_message = mock_email.call_args_list - - assert admin_message == call( - [admin._id], - 'email_transactional', - 'global_reviews', - admin, - registration, - self.MOCK_NOW, - comment='yo', - document_type='registration', - domain='http://localhost:5000/', - draft_registration=registration.draft_registration.get(), - has_psyarxiv_chronos_text=False, - is_creator=True, - is_rejected=False, - notify_comment='yo', - provider_contact_email=settings.OSF_CONTACT_EMAIL, - provider_support_email=settings.OSF_SUPPORT_EMAIL, - provider_url='http://localhost:5000/', - reviewable=registration, - requester=admin, - template='reviews_submission_status', - was_pending=False, - workflow=None - ) - - assert contrib_message == call( - [contrib._id], - 'email_transactional', - 'global_reviews', - admin, - registration, - self.MOCK_NOW, - comment='yo', - document_type='registration', - domain='http://localhost:5000/', - draft_registration=registration.draft_registration.get(), - has_psyarxiv_chronos_text=False, - is_creator=False, - is_rejected=False, - notify_comment='yo', - provider_contact_email=settings.OSF_CONTACT_EMAIL, - provider_support_email=settings.OSF_SUPPORT_EMAIL, - provider_url='http://localhost:5000/', - reviewable=registration, - requester=admin, - template='reviews_submission_status', - was_pending=False, - workflow=None - ) - def test_notify_moderator_registration_requests_withdrawal_notifications(self, moderator, daily_moderator, registration, admin, provider): + def test_withdrawal_registration_accepted_notifications( + self, registration_with_retraction, contrib, admin, withdraw_action + ): """ - [REQS-106] "As moderator, I receive registration withdrawal request notification email" - - :param mock_email: - :param draft_registration: - :param contrib: - :return: + [REQS-109] Authors receive notification when withdrawal is accepted. + Compare recipients by user objects via captured emits. """ - assert NotificationDigest.objects.count() == 0 - notify_moderator_registration_requests_withdrawal(registration, admin) - - assert NotificationDigest.objects.count() == 2 - - daily_digest = NotificationDigest.objects.get(send_type='email_digest') - transactional_digest = NotificationDigest.objects.get(send_type='email_transactional') - assert daily_digest.user == daily_moderator - assert transactional_digest.user == moderator + with capture_notifications() as notification: + notify_withdraw_registration(registration_with_retraction, withdraw_action) - for digest in (daily_digest, transactional_digest): - assert 'requested withdrawal' in digest.message - assert digest.event == 'new_pending_withdraw_requests' - assert digest.provider == provider + recipients = {rec['kwargs']['user'] for rec in notification['emits'] if 'user' in rec['kwargs']} + assert {admin, contrib}.issubset(recipients) - def test_withdrawal_registration_accepted_notifications(self, registration_with_retraction, contrib, admin, withdraw_action, mock_send_grid): + def test_withdrawal_registration_rejected_notifications( + self, registration, contrib, admin, withdraw_request_action + ): """ - [REQS-109] "As registration author(s) requesting registration withdrawal, we receive notification email of moderator - decision" - - :param mock_email: - :param draft_registration: - :param contrib: - :return: + [REQS-109] Authors receive notification when withdrawal is rejected. + Compare recipients by user objects via captured emits. """ - # Set up mock_send_mail as a pass-through to the original function. - # This lets us assert on the call count/args and also implicitly - # ensures that the email acutally renders as normal in send_mail. - notify_withdraw_registration(registration_with_retraction, withdraw_action) - - assert len(mock_send_grid.call_args_list) == 2 - admin_message, contrib_message = mock_send_grid.call_args_list + with capture_notifications() as notification: + notify_reject_withdraw_request(registration, withdraw_request_action) - assert admin_message[1]['to_addr'] == admin.email - assert contrib_message[1]['to_addr'] == contrib.email - assert admin_message[1]['subject'] == 'Your registration has been withdrawn' - assert contrib_message[1]['subject'] == 'Your registration has been withdrawn' + recipients = {rec['kwargs']['user'] for rec in notification['emits'] if 'user' in rec['kwargs']} + assert {admin, contrib}.issubset(recipients) - def test_withdrawal_registration_rejected_notifications(self, registration, contrib, admin, withdraw_request_action, mock_send_grid): + def test_withdrawal_registration_force_notifications( + self, registration_with_retraction, contrib, admin, withdraw_action + ): """ - [REQS-109] "As registration author(s) requesting registration withdrawal, we receive notification email of moderator - decision" - - :param mock_email: - :param draft_registration: - :param contrib: - :return: + [REQS-109] Forced withdrawal route: compare recipients by user objects via captured emits. """ - # Set up mock_send_mail as a pass-through to the original function. - # This lets us assert on the call count/args and also implicitly - # ensures that the email acutally renders as normal in send_mail. - notify_reject_withdraw_request(registration, withdraw_request_action) - - assert len(mock_send_grid.call_args_list) == 2 - admin_message, contrib_message = mock_send_grid.call_args_list + with capture_notifications() as notification: + notify_withdraw_registration(registration_with_retraction, withdraw_action) - assert admin_message[1]['to_addr'] == admin.email - assert contrib_message[1]['to_addr'] == contrib.email - assert admin_message[1]['subject'] == 'Your withdrawal request has been declined' - assert contrib_message[1]['subject'] == 'Your withdrawal request has been declined' + recipients = {rec['kwargs']['user'] for rec in notification['emits'] if 'user' in rec['kwargs']} + assert {admin, contrib}.issubset(recipients) - def test_withdrawal_registration_force_notifications(self, registration_with_retraction, contrib, admin, withdraw_action, mock_send_grid): - """ - [REQS-109] "As registration author(s) requesting registration withdrawal, we receive notification email of moderator - decision" - - :param mock_email: - :param draft_registration: - :param contrib: - :return: - """ - # Set up mock_send_mail as a pass-through to the original function. - # This lets us assert on the call count/args and also implicitly - # ensures that the email acutally renders as normal in send_mail. - notify_withdraw_registration(registration_with_retraction, withdraw_action) - - assert len(mock_send_grid.call_args_list) == 2 - admin_message, contrib_message = mock_send_grid.call_args_list - - assert admin_message[1]['to_addr'] == admin.email - assert contrib_message[1]['to_addr'] == contrib.email - assert admin_message[1]['subject'] == 'Your registration has been withdrawn' - assert contrib_message[1]['subject'] == 'Your registration has been withdrawn' - - @pytest.mark.parametrize( - 'digest_type, expected_recipient', - [('email_transactional', get_moderator), ('email_digest', get_daily_moderator)] - ) - def test_submissions_and_withdrawals_both_appear_in_moderator_digest(self, digest_type, expected_recipient, registration, admin, provider, mock_send_grid): - # Invoke the fixture function to get the recipient because parametrize - expected_recipient = expected_recipient(provider) - - notify_submit(registration, admin) - notify_moderator_registration_requests_withdrawal(registration, admin) - - # One user, one provider => one email - grouped_notifications = list(tasks.get_moderators_emails(digest_type)) - assert len(grouped_notifications) == 1 - - moderator_message = grouped_notifications[0] - assert moderator_message['user_id'] == expected_recipient._id - assert moderator_message['provider_id'] == provider.id - - # No fixed ordering of the entires, so just make sure that - # keywords for each action type are in some message - updates = moderator_message['info'] - assert len(updates) == 2 - assert any('submitted' in entry['message'] for entry in updates) - assert any('requested withdrawal' in entry['message'] for entry in updates) - - @pytest.mark.parametrize('digest_type', ['email_transactional', 'email_digest']) - def test_submsissions_and_withdrawals_do_not_appear_in_node_digest(self, digest_type, registration, admin, moderator, daily_moderator): - notify_submit(registration, admin) - notify_moderator_registration_requests_withdrawal(registration, admin) - - assert not list(tasks.get_users_emails(digest_type)) - - def test_moderator_digest_emails_render(self, registration, admin, moderator, mock_send_grid): - notify_moderator_registration_requests_withdrawal(registration, admin) - # Set up mock_send_mail as a pass-through to the original function. - # This lets us assert on the call count/args and also implicitly - # ensures that the email acutally renders as normal in send_mail. - tasks._send_reviews_moderator_emails('email_transactional') - - mock_send_grid.assert_called() + def test_moderator_digest_emails_render(self, registration, admin, moderator): + with capture_notifications(): + notify_moderator_registration_requests_withdrawal(registration, admin) + send_users_digest_email() def test_branded_provider_notification_renders(self, registration, admin, moderator): - # Set brand details to be checked in notify_base.mako provider = registration.provider provider.brand = Brand.objects.create(hero_logo_image='not-a-url', primary_color='#FFA500') provider.name = 'Test Provider' provider.save() - # Implicitly check that all of our uses of notify_base.mako render with branded details: - # - # notify_submit renders reviews_submission_confirmation using context from - # osf.utils.notifications and stores emails to be picked up in the moderator digest - # - # _send_Reviews_moderator_emails renders digest_reviews_moderators using context from - # website.notifications.tasks - notify_submit(registration, admin) - tasks._send_reviews_moderator_emails('email_transactional') - assert True # everything rendered! + with capture_notifications(): + notify_submit(registration, admin) + send_moderators_digest_email() diff --git a/osf_tests/test_registrations.py b/osf_tests/test_registrations.py index a93ffba6264..86e2208a301 100644 --- a/osf_tests/test_registrations.py +++ b/osf_tests/test_registrations.py @@ -10,6 +10,7 @@ from addons.wiki.models import WikiPage from osf.utils.permissions import ADMIN from osf.registrations.utils import get_registration_provider_submissions_url +from tests.utils import capture_notifications from website import settings @@ -159,7 +160,8 @@ def test_forked_from(self, registration, project, auth): # A a node that is not a fork assert registration.forked_from is None # A node that is a fork - fork = project.fork_node(auth) + with capture_notifications(): + fork = project.fork_node(auth) registration = factories.RegistrationFactory(project=fork) assert registration.forked_from == project @@ -326,7 +328,8 @@ def test_registration_gets_institution_affiliation(self, user): user.add_or_update_affiliated_institution(institution) user.save() - node.add_affiliated_institution(institution, user=user) + with capture_notifications(): + node.add_affiliated_institution(institution, user=user) node.save() registration = factories.RegistrationFactory(project=node) @@ -370,7 +373,8 @@ def test_registration_clones_project_wiki_pages(self, mock_signal, project, user def test_legacy_private_registrations_can_be_made_public(self, registration, auth): registration.is_public = False - registration.set_privacy(Node.PUBLIC, auth=auth) + with capture_notifications(): + registration.set_privacy(Node.PUBLIC, auth=auth) assert registration.is_public @@ -703,7 +707,8 @@ def test_embargo_states(self, embargo): registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.INITIAL.db_name - embargo.to_PENDING_MODERATION() + with capture_notifications(): + embargo.to_PENDING_MODERATION() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING.db_name @@ -729,7 +734,8 @@ def test_registration_approval_states(self, registration_approval): registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.INITIAL.db_name - registration_approval.to_PENDING_MODERATION() + with capture_notifications(): + registration_approval.to_PENDING_MODERATION() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING.db_name @@ -753,11 +759,13 @@ def test_retraction_states_over_registration_approval(self, registration_approva registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING_WITHDRAW_REQUEST.db_name - retraction.to_PENDING_MODERATION() + with capture_notifications(): + retraction.to_PENDING_MODERATION() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING_WITHDRAW.db_name - retraction.to_APPROVED() + with capture_notifications(): + retraction.to_APPROVED() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.WITHDRAWN.db_name @@ -776,11 +784,13 @@ def test_retraction_states_over_embargo(self, embargo): registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING_WITHDRAW_REQUEST.db_name - retraction.to_PENDING_MODERATION() + with capture_notifications(): + retraction.to_PENDING_MODERATION() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING_WITHDRAW.db_name - retraction.to_APPROVED() + with capture_notifications(): + retraction.to_APPROVED() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.WITHDRAWN.db_name @@ -822,11 +832,13 @@ def test_retraction_states_over_embargo_termination(self, embargo_termination): registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING_WITHDRAW_REQUEST.db_name - retraction.to_PENDING_MODERATION() + with capture_notifications(): + retraction.to_PENDING_MODERATION() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.PENDING_WITHDRAW.db_name - retraction.to_APPROVED() + with capture_notifications(): + retraction.to_APPROVED() registration.refresh_from_db() assert registration.moderation_state == RegistrationModerationStates.WITHDRAWN.db_name @@ -873,8 +885,12 @@ def unmoderated_registration(self): return registration def test_force_retraction_changes_state(self, moderated_registration, moderator): - moderated_registration.retract_registration( - user=moderator, justification='because', moderator_initiated=True) + with capture_notifications(): + moderated_registration.retract_registration( + user=moderator, + justification='because', + moderator_initiated=True + ) moderated_registration.refresh_from_db() assert moderated_registration.is_retracted @@ -883,8 +899,12 @@ def test_force_retraction_changes_state(self, moderated_registration, moderator) def test_force_retraction_writes_action(self, moderated_registration, moderator): justification = 'because power' - moderated_registration.retract_registration( - user=moderator, justification=justification, moderator_initiated=True) + with capture_notifications(): + moderated_registration.retract_registration( + user=moderator, + justification=justification, + moderator_initiated=True + ) expected_justification = 'Force withdrawn by moderator: ' + justification assert moderated_registration.retraction.justification == expected_justification diff --git a/osf_tests/test_reviewable.py b/osf_tests/test_reviewable.py index e3bc0b3d709..7d0c79dce22 100644 --- a/osf_tests/test_reviewable.py +++ b/osf_tests/test_reviewable.py @@ -1,13 +1,13 @@ from unittest import mock import pytest -from osf.models import Preprint +from osf.models import Preprint, NotificationType from osf.utils.workflows import DefaultStates from osf_tests.factories import PreprintFactory, AuthUserFactory +from tests.utils import capture_notifications @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestReviewable: @mock.patch('website.identifiers.utils.request_identifiers') @@ -16,7 +16,8 @@ def test_state_changes(self, _): preprint = PreprintFactory(reviews_workflow='pre-moderation', is_published=False) assert preprint.machine_state == DefaultStates.INITIAL.value - preprint.run_submit(user) + with capture_notifications(): + preprint.run_submit(user) assert preprint.machine_state == DefaultStates.PENDING.value preprint.run_accept(user, 'comment') @@ -34,23 +35,25 @@ def test_state_changes(self, _): from_db.refresh_from_db() assert from_db.machine_state == DefaultStates.ACCEPTED.value - def test_reject_resubmission_sends_emails(self, mock_send_grid): + def test_reject_resubmission_sends_emails(self): user = AuthUserFactory() preprint = PreprintFactory( reviews_workflow='pre-moderation', is_published=False ) assert preprint.machine_state == DefaultStates.INITIAL.value - assert not mock_send_grid.call_count - - preprint.run_submit(user) - assert mock_send_grid.call_count == 1 + with capture_notifications() as notifications: + preprint.run_submit(user) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION assert preprint.machine_state == DefaultStates.PENDING.value assert not user.notification_subscriptions.exists() preprint.run_reject(user, 'comment') assert preprint.machine_state == DefaultStates.REJECTED.value - preprint.run_submit(user) # Resubmission alerts users and moderators + with capture_notifications() as notifications: + preprint.run_submit(user) # Resubmission alerts users and moderators + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_REVIEWS_RESUBMISSION_CONFIRMATION assert preprint.machine_state == DefaultStates.PENDING.value - assert mock_send_grid.call_count == 2 diff --git a/osf_tests/test_s3_folder_migration.py b/osf_tests/test_s3_folder_migration.py deleted file mode 100644 index 067e63c34a3..00000000000 --- a/osf_tests/test_s3_folder_migration.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest -from osf.management.commands.add_colon_delim_to_s3_buckets import update_folder_names, reverse_update_folder_names - -@pytest.mark.django_db -class TestUpdateFolderNamesMigration: - - def test_update_folder_names_migration(self): - from addons.s3.models import NodeSettings - from addons.s3.tests.factories import S3NodeSettingsFactory - # Create sample folder names and IDs - S3NodeSettingsFactory(folder_name='Folder 1 (Location 1)', folder_id='folder1') - S3NodeSettingsFactory(folder_name='Folder 2', folder_id='folder2') - S3NodeSettingsFactory(folder_name='Folder 3 (Location 3)', folder_id='folder3') - S3NodeSettingsFactory(folder_name='Folder 4:/ (Location 4)', folder_id='folder4:/') - - update_folder_names() - - # Verify updated folder names and IDs - updated_folder_names_ids = NodeSettings.objects.values_list('folder_name', 'folder_id') - expected_updated_folder_names_ids = { - ('Folder 1:/ (Location 1)', 'folder1:/'), - ('Folder 2:/', 'folder2:/'), - ('Folder 3:/ (Location 3)', 'folder3:/'), - ('Folder 3:/ (Location 3)', 'folder3:/'), - ('Folder 4:/ (Location 4)', 'folder4:/'), - - } - assert set(updated_folder_names_ids) == expected_updated_folder_names_ids - - # Reverse the migration - reverse_update_folder_names() - - # Verify the folder names and IDs after the reverse migration - reverted_folder_names_ids = NodeSettings.objects.values_list('folder_name', 'folder_id') - expected_reverted_folder_names_ids = { - ('Folder 1 (Location 1)', 'folder1'), - ('Folder 2', 'folder2'), - ('Folder 3 (Location 3)', 'folder3'), - ('Folder 4 (Location 4)', 'folder4'), - } - assert set(reverted_folder_names_ids) == expected_reverted_folder_names_ids diff --git a/osf_tests/test_sanctions.py b/osf_tests/test_sanctions.py index de4161ced4a..1db4a3039b4 100644 --- a/osf_tests/test_sanctions.py +++ b/osf_tests/test_sanctions.py @@ -11,6 +11,7 @@ from osf_tests import factories from osf_tests.utils import mock_archive from osf.utils import permissions +from tests.utils import capture_notifications @pytest.mark.django_db @@ -135,7 +136,6 @@ def registration(self, request, contributor): registration.save() return registration - @mock.patch('website.mails.settings.USE_EMAIL', False) @pytest.mark.parametrize('reviews_workflow', [None, 'pre-moderation']) @pytest.mark.parametrize('branched_from_node', [True, False]) def test_render_admin_emails(self, registration, reviews_workflow, branched_from_node): @@ -146,10 +146,10 @@ def test_render_admin_emails(self, registration, reviews_workflow, branched_from registration.branched_from_node = branched_from_node registration.save() - registration.sanction.ask([(registration.creator, registration)]) + with capture_notifications(): + registration.sanction.ask([(registration.creator, registration)]) assert True # mail rendered successfully - @mock.patch('website.mails.settings.USE_EMAIL', False) @pytest.mark.parametrize('reviews_workflow', [None, 'pre-moderation']) @pytest.mark.parametrize('branched_from_node', [True, False]) def test_render_non_admin_emails( @@ -161,7 +161,8 @@ def test_render_non_admin_emails( registration.branched_from_node = branched_from_node registration.save() - registration.sanction.ask([(contributor, registration)]) + with capture_notifications(): + registration.sanction.ask([(contributor, registration)]) assert True # mail rendered successfully @@ -213,7 +214,8 @@ def test_moderated_sanction__no_identifier_created_until_moderator_approval(self provider.get_group('moderator').user_set.add(moderator) # Admin approval - registration.sanction.accept() + with capture_notifications(): + registration.sanction.accept() assert not registration.get_identifier(category='doi') # Moderator approval diff --git a/osf_tests/test_schema_responses.py b/osf_tests/test_schema_responses.py index 40965c7cf31..96c29485fb2 100644 --- a/osf_tests/test_schema_responses.py +++ b/osf_tests/test_schema_responses.py @@ -1,16 +1,14 @@ -from unittest import mock import pytest from api.providers.workflows import Workflows from framework.exceptions import PermissionsError from osf.exceptions import PreviousSchemaResponseError, SchemaResponseStateError, SchemaResponseUpdateError -from osf.models import RegistrationSchema, RegistrationSchemaBlock, SchemaResponseBlock +from osf.models import RegistrationSchema, RegistrationSchemaBlock, SchemaResponseBlock, NotificationType from osf.models import schema_response # import module for mocking purposes from osf.utils.workflows import ApprovalStates, SchemaResponseTriggers from osf_tests.factories import AuthUserFactory, ProjectFactory, RegistrationFactory, RegistrationProviderFactory -from osf_tests.utils import get_default_test_schema, _ensure_subscriptions - -from website.notifications import emails +from osf_tests.utils import get_default_test_schema +from tests.utils import capture_notifications from transitions import MachineError @@ -86,16 +84,16 @@ def initial_response(registration): @pytest.fixture def revised_response(initial_response): - revised_response = schema_response.SchemaResponse.create_from_previous_response( - previous_response=initial_response, - initiator=initial_response.initiator - ) + with capture_notifications(): + revised_response = schema_response.SchemaResponse.create_from_previous_response( + previous_response=initial_response, + initiator=initial_response.initiator + ) return revised_response @pytest.mark.enable_bookmark_creation @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestCreateSchemaResponse(): def test_create_initial_response_sets_attributes(self, registration, schema): @@ -142,11 +140,11 @@ def test_create_initial_response_assigns_default_values(self, registration): for block in response.response_blocks.all(): assert block.response == DEFAULT_SCHEMA_RESPONSE_VALUES[block.schema_key] - def test_create_initial_response_does_not_notify(self, registration, admin_user, mock_send_grid): + def test_create_initial_response_does_not_notify(self, registration, admin_user): schema_response.SchemaResponse.create_initial_response( - parent=registration, initiator=admin_user + parent=registration, + initiator=admin_user ) - assert not mock_send_grid.called def test_create_initial_response_fails_if_no_schema_and_no_parent_schema(self, registration): registration.registered_schema.clear() @@ -235,11 +233,12 @@ def test_create_initial_response_for_different_parent(self, registration): ).exists() def test_create_from_previous_response(self, registration, initial_response): - revised_response = schema_response.SchemaResponse.create_from_previous_response( - initiator=registration.creator, - previous_response=initial_response, - justification='Leeeeerooooy Jeeeenkiiiinns' - ) + with capture_notifications(): + revised_response = schema_response.SchemaResponse.create_from_previous_response( + initiator=registration.creator, + previous_response=initial_response, + justification='Leeeeerooooy Jeeeenkiiiinns' + ) assert revised_response.initiator == registration.creator assert revised_response.parent == registration @@ -252,13 +251,17 @@ def test_create_from_previous_response(self, registration, initial_response): assert set(revised_response.response_blocks.all()) == set(initial_response.response_blocks.all()) def test_create_from_previous_response_notification( - self, initial_response, admin_user, notification_recipients, mock_send_grid): + self, initial_response, admin_user, notification_recipients): - schema_response.SchemaResponse.create_from_previous_response( - previous_response=initial_response, initiator=admin_user - ) - - assert mock_send_grid.called + with capture_notifications() as notifications: + schema_response.SchemaResponse.create_from_previous_response( + previous_response=initial_response, + initiator=admin_user + ) + assert len(notifications['emits']) == len(notification_recipients) + assert all(notification['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_INITIATED + for notification in notifications['emits']) + assert all(notification['kwargs']['user'].username in notification_recipients for notification in notifications['emits']) @pytest.mark.parametrize( 'invalid_response_state', @@ -277,10 +280,11 @@ def test_create_from_previous_response_fails_if_parent_has_unapproved_response( # Making a valid revised response, then pushing the initial response into an # invalid state to ensure that `create_from_previous_response` fails if # *any* schema_response on the parent is unapproved - intermediate_response = schema_response.SchemaResponse.create_from_previous_response( - initiator=initial_response.initiator, - previous_response=initial_response - ) + with capture_notifications(): + intermediate_response = schema_response.SchemaResponse.create_from_previous_response( + initiator=initial_response.initiator, + previous_response=initial_response + ) intermediate_response.approvals_state_machine.set_state(ApprovalStates.APPROVED) intermediate_response.save() @@ -507,10 +511,12 @@ def test_delete_schema_response_deletes_schema_response_blocks(self, initial_res assert not SchemaResponseBlock.objects.exists() def test_delete_revised_response_only_deletes_updated_blocks(self, initial_response): - revised_response = schema_response.SchemaResponse.create_from_previous_response( - previous_response=initial_response, - initiator=initial_response.initiator - ) + + with capture_notifications(): + revised_response = schema_response.SchemaResponse.create_from_previous_response( + previous_response=initial_response, + initiator=initial_response.initiator + ) revised_response.update_responses({'q1': 'blahblahblah', 'q2': 'whoopdedoo'}) old_blocks = initial_response.response_blocks.all() @@ -542,7 +548,6 @@ def test_delete_fails_if_state_is_invalid(self, invalid_response_state, initial_ @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestUnmoderatedSchemaResponseApprovalFlows(): def test_submit_response_adds_pending_approvers( @@ -574,23 +579,25 @@ def test_submit_response_writes_schema_response_action(self, initial_response, a assert new_action.trigger == SchemaResponseTriggers.SUBMIT.db_name def test_submit_response_notification( - self, revised_response, admin_user, notification_recipients, mock_send_grid): + self, revised_response, admin_user, notification_recipients): revised_response.approvals_state_machine.set_state(ApprovalStates.IN_PROGRESS) revised_response.update_responses({'q1': 'must change one response or can\'t submit'}) revised_response.revision_justification = 'has for valid revision_justification for submission' revised_response.save() - revised_response.submit(user=admin_user, required_approvers=[admin_user]) - - assert mock_send_grid.called + with capture_notifications() as notifications: + revised_response.submit(user=admin_user, required_approvers=[admin_user]) + assert len(notifications['emits']) == 3 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_SUBMITTED + assert notifications['emits'][1]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_SUBMITTED + assert notifications['emits'][2]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_SUBMITTED - def test_no_submit_notification_on_initial_response(self, initial_response, admin_user, mock_send_grid): + def test_no_submit_notification_on_initial_response(self, initial_response, admin_user): initial_response.approvals_state_machine.set_state(ApprovalStates.IN_PROGRESS) initial_response.update_responses({'q1': 'must change one response or can\'t submit'}) initial_response.revision_justification = 'has for valid revision_justification for submission' initial_response.save() initial_response.submit(user=admin_user, required_approvers=[admin_user]) - assert not mock_send_grid.called def test_submit_response_requires_user(self, initial_response, admin_user): initial_response.approvals_state_machine.set_state(ApprovalStates.IN_PROGRESS) @@ -672,23 +679,21 @@ def test_approve_response_writes_schema_response_action( ).count() == 2 def test_approve_response_notification( - self, revised_response, admin_user, alternate_user, notification_recipients, mock_send_grid): + self, revised_response, admin_user, alternate_user, notification_recipients): revised_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) revised_response.save() revised_response.pending_approvers.add(admin_user, alternate_user) - mock_send_grid.reset_mock() - revised_response.approve(user=admin_user) - assert not mock_send_grid.called # Should only send email on final approval - revised_response.approve(user=alternate_user) - assert mock_send_grid.called + revised_response.approve(user=admin_user) # Should only send email on final approval + with capture_notifications() as notifications: + revised_response.approve(user=alternate_user) + assert len(notifications['emits']) == 3 - def test_no_approve_notification_on_initial_response(self, initial_response, admin_user, mock_send_grid): + def test_no_approve_notification_on_initial_response(self, initial_response, admin_user): initial_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) initial_response.save() initial_response.pending_approvers.add(admin_user) initial_response.approve(user=admin_user) - assert not mock_send_grid.called def test_approve_response_requires_user(self, initial_response, admin_user): initial_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) @@ -739,22 +744,23 @@ def test_reject_response_writes_schema_response_action(self, initial_response, a assert new_action.trigger == SchemaResponseTriggers.ADMIN_REJECT.db_name def test_reject_response_notification( - self, revised_response, admin_user, notification_recipients, mock_send_grid): + self, revised_response, admin_user, notification_recipients): revised_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) revised_response.save() revised_response.pending_approvers.add(admin_user) - revised_response.reject(user=admin_user) + with capture_notifications() as notifications: + revised_response.reject(user=admin_user) + assert len(notifications['emits']) == 3 + assert all(notification['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_REJECTED + for notification in notifications['emits']) - assert mock_send_grid.called - - def test_no_reject_notification_on_initial_response(self, initial_response, admin_user, mock_send_grid): + def test_no_reject_notification_on_initial_response(self, initial_response, admin_user): initial_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) initial_response.save() initial_response.pending_approvers.add(admin_user) initial_response.reject(user=admin_user) - assert not mock_send_grid.called def test_reject_response_requires_user(self, initial_response, admin_user): initial_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) @@ -801,14 +807,12 @@ def test_internal_accept_clears_pending_approvers(self, initial_response, admin_ @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestModeratedSchemaResponseApprovalFlows(): @pytest.fixture def provider(self): provider = RegistrationProviderFactory() provider.update_group_permissions() - _ensure_subscriptions(provider) provider.reviews_workflow = Workflows.PRE_MODERATION.value provider.save() return provider @@ -848,26 +852,35 @@ def test_schema_response_action_to_state_following_moderated_approve_is_pending_ assert new_action.to_state == ApprovalStates.PENDING_MODERATION.db_name assert new_action.trigger == SchemaResponseTriggers.APPROVE.db_name - def test_accept_notification_sent_on_admin_approval(self, revised_response, admin_user, mock_send_grid): + def test_accept_notification_sent_on_admin_approval(self, revised_response, admin_user, moderator): revised_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) revised_response.save() revised_response.pending_approvers.add(admin_user) - revised_response.approve(user=admin_user) - assert mock_send_grid.called + with capture_notifications() as notifications: + revised_response.approve(user=admin_user) + assert len(notifications['emits']) == 3 + assert notifications['emits'][0]['kwargs']['user'] == moderator + assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + assert notifications['emits'][1]['kwargs']['user'] == moderator + assert notifications['emits'][1]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + assert notifications['emits'][2]['kwargs']['user'] == admin_user + assert notifications['emits'][2]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_APPROVED def test_moderators_notified_on_admin_approval(self, revised_response, admin_user, moderator): revised_response.approvals_state_machine.set_state(ApprovalStates.UNAPPROVED) revised_response.save() revised_response.pending_approvers.add(admin_user) - store_emails = emails.store_emails - with mock.patch.object(emails, 'store_emails', autospec=True) as mock_store: - mock_store.side_effect = store_emails + with capture_notifications() as notifications: revised_response.approve(user=admin_user) - - assert mock_store.called - assert mock_store.call_args[0][0] == [moderator._id] + assert len(notifications['emits']) == 3 + assert notifications['emits'][0]['kwargs']['user'] == moderator + assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + assert notifications['emits'][1]['kwargs']['user'] == moderator + assert notifications['emits'][1]['type'] == NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS + assert notifications['emits'][2]['kwargs']['user'] == admin_user + assert notifications['emits'][2]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_APPROVED def test_no_moderator_notification_on_admin_approval_of_initial_response( self, initial_response, admin_user): @@ -875,9 +888,7 @@ def test_no_moderator_notification_on_admin_approval_of_initial_response( initial_response.save() initial_response.pending_approvers.add(admin_user) - with mock.patch.object(emails, 'store_emails', autospec=True) as mock_store: - initial_response.approve(user=admin_user) - assert not mock_store.called + initial_response.approve(user=admin_user) def test_moderator_accept(self, initial_response, moderator): initial_response.approvals_state_machine.set_state(ApprovalStates.PENDING_MODERATION) @@ -900,21 +911,23 @@ def test_moderator_accept_writes_schema_response_action(self, initial_response, assert new_action.trigger == SchemaResponseTriggers.ACCEPT.db_name def test_moderator_accept_notification( - self, revised_response, moderator, notification_recipients, mock_send_grid): + self, revised_response, moderator, notification_recipients): revised_response.approvals_state_machine.set_state(ApprovalStates.PENDING_MODERATION) revised_response.save() - revised_response.accept(user=moderator) - - assert mock_send_grid.called + with capture_notifications() as notifications: + revised_response.accept(user=moderator) + assert len(notifications['emits']) == 3 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_APPROVED + assert notifications['emits'][1]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_APPROVED + assert notifications['emits'][2]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_APPROVED def test_no_moderator_accept_notification_on_initial_response( - self, initial_response, moderator, mock_send_grid): + self, initial_response, moderator): initial_response.approvals_state_machine.set_state(ApprovalStates.PENDING_MODERATION) initial_response.save() initial_response.accept(user=moderator) - assert not mock_send_grid.called def test_moderator_reject(self, initial_response, admin_user, moderator): initial_response.approvals_state_machine.set_state(ApprovalStates.PENDING_MODERATION) @@ -938,21 +951,23 @@ def test_moderator_reject_writes_schema_response_action( assert new_action.trigger == SchemaResponseTriggers.MODERATOR_REJECT.db_name def test_moderator_reject_notification( - self, revised_response, moderator, notification_recipients, mock_send_grid): + self, revised_response, moderator, notification_recipients): revised_response.approvals_state_machine.set_state(ApprovalStates.PENDING_MODERATION) revised_response.save() - revised_response.reject(user=moderator) - - assert mock_send_grid.called + with capture_notifications() as notifications: + revised_response.reject(user=moderator) + assert len(notifications['emits']) == 3 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_REJECTED + assert notifications['emits'][1]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_REJECTED + assert notifications['emits'][2]['type'] == NotificationType.Type.NODE_SCHEMA_RESPONSE_REJECTED def test_no_moderator_reject_notification_on_initial_response( - self, initial_response, moderator, mock_send_grid): + self, initial_response, moderator): initial_response.approvals_state_machine.set_state(ApprovalStates.PENDING_MODERATION) initial_response.save() initial_response.reject(user=moderator) - assert not mock_send_grid.called def test_moderator_cannot_submit(self, initial_response, moderator): initial_response.approvals_state_machine.set_state(ApprovalStates.IN_PROGRESS) diff --git a/osf_tests/test_user.py b/osf_tests/test_user.py index 3a2e508dd2d..6cddff997c0 100644 --- a/osf_tests/test_user.py +++ b/osf_tests/test_user.py @@ -18,6 +18,7 @@ from framework.auth.signals import user_account_merged from framework.analytics import get_total_activity_count from framework.exceptions import PermissionsError +from tests.utils import capture_notifications from website import settings from website import filters from website.views import find_bookmark_collection @@ -32,7 +33,7 @@ DraftRegistrationContributor, DraftRegistration, DraftNode, - UserSessionMap, + UserSessionMap, NotificationType, ) from osf.models.institution_affiliation import get_user_by_institution_identity from addons.github.tests.factories import GitHubAccountFactory @@ -739,8 +740,10 @@ def test_display_full_name_unregistered(self): u = UnregUserFactory() project = NodeFactory() project.add_unregistered_contributor( - fullname=name, email=u.username, - auth=Auth(project.creator) + fullname=name, + email=u.username, + auth=Auth(project.creator), + notification_type=False ) project.save() u.reload() @@ -751,8 +754,10 @@ def test_repeat_add_same_unreg_user_with_diff_name(self): project = NodeFactory() old_name = unreg_user.fullname project.add_unregistered_contributor( - fullname=old_name, email=unreg_user.username, - auth=Auth(project.creator) + fullname=old_name, + email=unreg_user.username, + auth=Auth(project.creator), + notification_type=False ) project.save() unreg_user.reload() @@ -764,8 +769,10 @@ def test_repeat_add_same_unreg_user_with_diff_name(self): assert unreg_user not in project.contributors new_name = fake.name() project.add_unregistered_contributor( - fullname=new_name, email=unreg_user.username, - auth=Auth(project.creator) + fullname=new_name, + email=unreg_user.username, + auth=Auth(project.creator), + notification_type=False ) project.save() unreg_user.reload() @@ -885,31 +892,34 @@ def test_get_user_by_cookie_no_session(self): assert OSFUser.from_cookie(cookie) is None -@pytest.mark.usefixtures('mock_send_grid') class TestChangePassword: def test_change_password(self, user): old_password = 'password' new_password = 'new password' confirm_password = new_password - user.set_password(old_password) + with capture_notifications(): + user.set_password(old_password) user.save() - user.change_password(old_password, new_password, confirm_password) + with capture_notifications(): + user.change_password(old_password, new_password, confirm_password) assert bool(user.check_password(new_password)) is True - def test_set_password_notify_default(self, mock_send_grid, user): + def test_set_password_notify_default(self, user): old_password = 'password' - user.set_password(old_password) - user.save() - assert mock_send_grid.called is True + with capture_notifications() as notifications: + user.set_password(old_password) + user.save() - def test_set_password_no_notify(self, mock_send_grid, user): + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_PASSWORD_RESET + + def test_set_password_no_notify(self, user): old_password = 'password' user.set_password(old_password, notify=False) user.save() - assert mock_send_grid.called is False - def test_check_password_upgrade_hasher_no_notify(self, mock_send_grid, user, settings): + def test_check_password_upgrade_hasher_no_notify(self, user, settings): # NOTE: settings fixture comes from pytest-django. # changes get reverted after tests run settings.PASSWORD_HASHERS = ( @@ -920,15 +930,16 @@ def test_check_password_upgrade_hasher_no_notify(self, mock_send_grid, user, set user.password = 'sha1$lNb72DKWDv6P$e6ae16dada9303ae0084e14fc96659da4332bb05' user.check_password(raw_password) assert user.password.startswith('md5$') - assert mock_send_grid.called is False def test_change_password_invalid(self, old_password=None, new_password=None, confirm_password=None, error_message='Old password is invalid'): user = UserFactory() - user.set_password('password') + with capture_notifications(): + user.set_password('password') user.save() with pytest.raises(ChangePasswordError, match=error_message): - user.change_password(old_password, new_password, confirm_password) + with capture_notifications(): + user.change_password(old_password, new_password, confirm_password) user.save() assert bool(user.check_password(new_password)) is False @@ -995,7 +1006,8 @@ def func(**attrs): is_disabled=False, date_confirmed=timezone.now(), ) - user.set_password('secret') + with capture_notifications(): + user.set_password('secret') for attr, value in attrs.items(): setattr(user, attr, value) return user @@ -2087,7 +2099,13 @@ def project_user_is_only_admin(self, user): non_admin_contrib = UserFactory() project = ProjectFactory(creator=user) project.add_contributor(non_admin_contrib) - project.add_unregistered_contributor('lisa', 'lisafrank@cos.io', permissions=permissions.ADMIN, auth=Auth(user)) + project.add_unregistered_contributor( + 'lisa', + 'lisafrank@cos.io', + permissions=permissions.ADMIN, + auth=Auth(user), + notification_type=False + ) project.save() return project diff --git a/osf_tests/utils.py b/osf_tests/utils.py index a8364a15478..adb00482168 100644 --- a/osf_tests/utils.py +++ b/osf_tests/utils.py @@ -16,7 +16,6 @@ Sanction, RegistrationProvider, RegistrationSchema, - NotificationSubscription ) from osf.utils.migrations import create_schema_blocks_for_atomic_schema @@ -219,36 +218,3 @@ def get_default_test_schema(): create_schema_blocks_for_atomic_schema(test_schema) return test_schema - - -def _ensure_subscriptions(provider): - '''Make sure a provider's subscriptions exist. - - Provider subscriptions are populated by an on_save signal when the provider is created. - This has led to observed race conditions and probabalistic test failures. - Avoid that. - ''' - for subscription in provider.DEFAULT_SUBSCRIPTIONS: - NotificationSubscription.objects.get_or_create( - _id=f'{provider._id}_{subscription}', - event_name=subscription, - provider=provider - ) - -def assert_notification_correctness(send_mail_mock, expected_template, expected_recipients): - '''Confirms that a mocked send_mail function contains the appropriate calls.''' - assert send_mail_mock.call_count == len(expected_recipients) - - recipients = set() - templates = set() - for _, call_kwargs in send_mail_mock.call_args_list: - recipients.add(call_kwargs['to_addr']) - templates.add(call_kwargs['mail']) - - assert recipients == expected_recipients - - try: - assert templates == {expected_template} - except AssertionError: # the non-static subject attributes mean we need a different comparison - assert {template.tpl_prefix for template in list(templates)} == {expected_template.tpl_prefix} - assert {template._subject for template in list(templates)} == {expected_template._subject} diff --git a/pytest.ini b/pytest.ini index f0126e4dfd5..4417f537dd0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,5 +1,5 @@ [pytest] -addopts = --ds=osf_tests.settings --tb=short --reuse-db --allow-hosts=127.0.0.1,192.168.168.167,localhost +addopts = --ds=osf_tests.settings --tb=short --reuse-db --allow-hosts=127.0.0.1,192.168.168.167,localhost,mailhog filterwarnings = once::UserWarning ignore:.*U.*mode is deprecated:DeprecationWarning diff --git a/scripts/add_global_subscriptions.py b/scripts/add_global_subscriptions.py deleted file mode 100644 index b326c6f9f67..00000000000 --- a/scripts/add_global_subscriptions.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -This migration subscribes each user to USER_SUBSCRIPTIONS_AVAILABLE if a subscription -does not already exist. -""" - -import logging -import sys - -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 - -from scripts import utils as scripts_utils - -logger = logging.getLogger(__name__) - -def add_global_subscriptions(dry=True): - OSFUser = apps.get_model('osf.OSFUser') - notification_type = 'email_transactional' - user_events = constants.USER_SUBSCRIPTIONS_AVAILABLE - - count = 0 - - with transaction.atomic(): - for user in OSFUser.objects.filter(is_registered=True, date_confirmed__isnull=False): - changed = False - if not user.is_active: - continue - for user_event in user_events: - user_event_id = to_subscription_key(user._id, user_event) - - subscription = NotificationSubscription.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.save() # Need to save in order to access m2m fields - subscription.add_user_to_subscription(user, notification_type) - subscription.save() - changed = True - else: - logger.info(f'User {user._id} already has a {user_event} subscription') - if changed: - count += 1 - - logger.info(f'Added subscriptions for {count} users') - if dry: - raise RuntimeError('Dry mode -- rolling back transaction') - -if __name__ == '__main__': - dry = '--dry' in sys.argv - init_app(routes=False) - if not dry: - scripts_utils.add_file_logger(logger, __file__) - add_global_subscriptions(dry=dry) diff --git a/scripts/create_fakes.py b/scripts/create_fakes.py index 8b4db177de7..379331f24bc 100644 --- a/scripts/create_fakes.py +++ b/scripts/create_fakes.py @@ -256,7 +256,6 @@ def science_text(cls, max_nb_chars=200): logger = logging.getLogger('create_fakes') SILENT_LOGGERS = [ 'factory', - 'website.mails', ] for logger_name in SILENT_LOGGERS: logging.getLogger(logger_name).setLevel(logging.CRITICAL) diff --git a/scripts/osfstorage/usage_audit.py b/scripts/osfstorage/usage_audit.py index 8a8ffb6c1f1..200f3fda0e7 100644 --- a/scripts/osfstorage/usage_audit.py +++ b/scripts/osfstorage/usage_audit.py @@ -21,10 +21,10 @@ from framework.celery_tasks import app as celery_app from osf.models import TrashedFile, Node -from website import mails from website.app import init_app from website.settings.defaults import GBs +from django.core.mail import send_mail from scripts import utils as scripts_utils # App must be init'd before django models are imported @@ -110,7 +110,7 @@ def main(send_email=False): if lines: if send_email: logger.info('Sending email...') - mails.send_mail('support+scripts@osf.io', mails.EMPTY, body='\n'.join(lines), subject='Script: OsfStorage usage audit', can_change_preferences=False,) + send_mail('support+scripts@osf.io', mails.EMPTY, body='\n'.join(lines), subject='Script: OsfStorage usage audit', can_change_preferences=False,) else: logger.info(f'send_email is False, not sending email') logger.info(f'{len(lines)} offending project(s) and user(s) found') diff --git a/scripts/remove_notification_subscriptions_from_registrations.py b/scripts/remove_notification_subscriptions_from_registrations.py deleted file mode 100644 index 8984cb25b50..00000000000 --- a/scripts/remove_notification_subscriptions_from_registrations.py +++ /dev/null @@ -1,39 +0,0 @@ -""" Script for removing NotificationSubscriptions from registrations. - Registrations shouldn't have them! -""" -import logging -import sys - -import django -django.setup() - -from website.app import init_app -from django.apps import apps - -logger = logging.getLogger(__name__) - - -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') - registrations_affected = Registration.objects.filter( - id__in=notifications_to_delete.values_list( - 'node_id', flat=True - ) - ) - logger.info(f'{notifications_to_delete.count()} NotificationSubscriptions will be deleted.') - logger.info('{} Registrations will be affected: {}'.format( - registrations_affected.count(), - list(registrations_affected.values_list('guids___id', flat=True))) - ) - - if not dry_run: - notifications_to_delete.delete() - logger.info('Registration Notification Subscriptions removed.') - -if __name__ == '__main__': - dry_run = '--dry' in sys.argv - init_app(routes=False) - remove_notification_subscriptions_from_registrations(dry_run=dry_run) diff --git a/scripts/send_queued_mails.py b/scripts/send_queued_mails.py deleted file mode 100644 index 7c70c7685a0..00000000000 --- a/scripts/send_queued_mails.py +++ /dev/null @@ -1,66 +0,0 @@ -import logging - -import django -from django.db import transaction -from django.utils import timezone -django.setup() - -from framework.celery_tasks import app as celery_app - -from osf.models.queued_mail import QueuedMail -from website.app import init_app -from website import settings - -from scripts.utils import add_file_logger - - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.INFO) - - -def main(dry_run=True): - # find all emails to be sent, pops the top one for each user(to obey the once - # a week requirement), checks to see if one has been sent this week, and if - # not send the email, otherwise leave it in the queue - - user_queue = {} - for email in find_queued_mails_ready_to_be_sent(): - user_queue.setdefault(email.user._id, []).append(email) - - emails_to_be_sent = pop_and_verify_mails_for_each_user(user_queue) - - logger.info(f'Emails being sent at {timezone.now().isoformat()}') - - for mail in emails_to_be_sent: - if not dry_run: - with transaction.atomic(): - try: - sent_ = mail.send_mail() - message = f'Email of type {mail.email_type} sent to {mail.to_addr}' if sent_ else \ - f'Email of type {mail.email_type} failed to be sent to {mail.to_addr}' - logger.info(message) - except Exception as error: - logger.error(f'Email of type {mail.email_type} to be sent to {mail.to_addr} caused an ERROR') - logger.exception(error) - pass - else: - logger.info(f'Email of type {mail.email_type} will be sent to {mail.to_addr}') - - -def find_queued_mails_ready_to_be_sent(): - return QueuedMail.objects.filter(send_at__lt=timezone.now(), sent_at__isnull=True) - -def pop_and_verify_mails_for_each_user(user_queue): - for user_emails in user_queue.values(): - mail = user_emails[0] - mails_past_week = mail.user.queuedmail_set.filter(sent_at__gt=timezone.now() - settings.WAIT_BETWEEN_MAILS) - if not mails_past_week.count(): - yield mail - - -@celery_app.task(name='scripts.send_queued_mails') -def run_main(dry_run=True): - init_app(routes=False) - if not dry_run: - add_file_logger(logger, __file__) - main(dry_run=dry_run) diff --git a/scripts/stuck_registration_audit.py b/scripts/stuck_registration_audit.py index b5445873faf..36eca5e52ab 100644 --- a/scripts/stuck_registration_audit.py +++ b/scripts/stuck_registration_audit.py @@ -9,15 +9,13 @@ from django.utils import timezone -from website import mails from website import settings from framework.auth import Auth from framework.celery_tasks import app as celery_app from osf.management.commands import force_archive as fa -from osf.models import ArchiveJob, Registration -from website.archiver import ARCHIVER_INITIATED -from website.settings import ARCHIVE_TIMEOUT_TIMEDELTA, ADDONS_REQUESTED +from osf.models import Registration, NotificationType +from website.settings import ADDONS_REQUESTED from scripts import utils as scripts_utils @@ -97,13 +95,14 @@ def main(): dict_writer.writeheader() dict_writer.writerows(broken_registrations) - mails.send_mail( - mail=mails.ARCHIVE_REGISTRATION_STUCK_DESK, - to_addr=settings.OSF_SUPPORT_EMAIL, - broken_registrations=broken_registrations, - attachment_name=filename, - attachment_content=output.getvalue(), - can_change_preferences=False, + NotificationType.Type.DESK_ARCHIVE_REGISTRATION_STUCK.instance.emit( + destination_address=settings.OSF_SUPPORT_EMAIL, + event_context={ + 'broken_registrations_count': len(broken_registrations), + 'attachment_name': filename, + 'attachement_content': output.getvalue(), + 'can_change_preferences': False + } ) logger.info(f'{len(broken_registrations)} broken registrations found') diff --git a/scripts/tests/test_deactivate_requested_accounts.py b/scripts/tests/test_deactivate_requested_accounts.py index 07e43f74bf2..d2adf6f76fe 100644 --- a/scripts/tests/test_deactivate_requested_accounts.py +++ b/scripts/tests/test_deactivate_requested_accounts.py @@ -1,12 +1,13 @@ import pytest +from osf.models import NotificationType from osf_tests.factories import ProjectFactory, AuthUserFactory from osf.management.commands.deactivate_requested_accounts import deactivate_requested_accounts +from tests.utils import capture_notifications @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') class TestDeactivateRequestedAccount: @pytest.fixture() @@ -24,21 +25,25 @@ def user_requested_deactivation_with_node(self): user.save() return user - def test_deactivate_user_with_no_content(self, mock_send_grid, user_requested_deactivation): + def test_deactivate_user_with_no_content(self, user_requested_deactivation): - deactivate_requested_accounts(dry_run=False) + with capture_notifications() as notifications: + deactivate_requested_accounts(dry_run=False) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_REQUEST_DEACTIVATION_COMPLETE user_requested_deactivation.reload() assert user_requested_deactivation.requested_deactivation assert user_requested_deactivation.contacted_deactivation assert user_requested_deactivation.is_disabled - mock_send_grid.assert_called() - def test_deactivate_user_with_content(self, mock_send_grid, user_requested_deactivation_with_node): + def test_deactivate_user_with_content(self, user_requested_deactivation_with_node): - deactivate_requested_accounts(dry_run=False) + with capture_notifications() as notifications: + deactivate_requested_accounts(dry_run=False) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.DESK_REQUEST_DEACTIVATION user_requested_deactivation_with_node.reload() assert user_requested_deactivation_with_node.requested_deactivation assert not user_requested_deactivation_with_node.is_disabled - mock_send_grid.assert_called() diff --git a/scripts/tests/test_fix_registration_unclaimed_records.py b/scripts/tests/test_fix_registration_unclaimed_records.py index 8b408760428..10d6bc065b0 100644 --- a/scripts/tests/test_fix_registration_unclaimed_records.py +++ b/scripts/tests/test_fix_registration_unclaimed_records.py @@ -27,13 +27,23 @@ def project(self, user, auth): @pytest.fixture() def contributor_unregistered(self, user, auth, project): - ret = project.add_unregistered_contributor(fullname='Johnny Git Gud', email='ford.prefect@hitchhikers.com', auth=auth) + ret = project.add_unregistered_contributor( + fullname='Jason Kelece', + email='burds@eagles.com', + auth=auth, + notification_type=False + ) project.save() return ret @pytest.fixture() def contributor_unregistered_no_email(self, user, auth, project): - ret = project.add_unregistered_contributor(fullname='Johnny B. Bard', email='', auth=auth) + ret = project.add_unregistered_contributor( + fullname='Big Play Slay', + email='', + auth=auth, + notification_type=False + ) project.save() return ret diff --git a/scripts/tests/test_send_queued_mails.py b/scripts/tests/test_send_queued_mails.py deleted file mode 100644 index 2815b85f5d9..00000000000 --- a/scripts/tests/test_send_queued_mails.py +++ /dev/null @@ -1,84 +0,0 @@ -from unittest import mock -from datetime import timedelta - -from django.utils import timezone - -from tests.base import OsfTestCase -from osf_tests.factories import UserFactory -from osf.models.queued_mail import QueuedMail, queue_mail, NO_ADDON, NO_LOGIN_TYPE - -from scripts.send_queued_mails import main, pop_and_verify_mails_for_each_user, find_queued_mails_ready_to_be_sent -from website import settings - -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) -class TestSendQueuedMails(OsfTestCase): - - def setUp(self): - super().setUp() - self.user = UserFactory() - self.user.date_last_login = timezone.now() - self.user.osf_mailing_lists[settings.OSF_HELP_LIST] = True - self.user.save() - - from conftest import start_mock_send_grid - self.mock_send_grid = start_mock_send_grid(self) - - - def queue_mail(self, mail_type=NO_ADDON, user=None, send_at=None): - return queue_mail( - to_addr=user.username if user else self.user.username, - mail=mail_type, - send_at=send_at or timezone.now(), - user=user if user else self.user, - fullname=user.fullname if user else self.user.fullname, - ) - - def test_queue_addon_mail(self): - self.queue_mail() - main(dry_run=False) - assert self.mock_send_grid.called - - def test_no_two_emails_to_same_person(self): - user = UserFactory() - user.osf_mailing_lists[settings.OSF_HELP_LIST] = True - user.save() - self.queue_mail(user=user) - self.queue_mail(user=user) - main(dry_run=False) - assert self.mock_send_grid.call_count == 1 - - def test_pop_and_verify_mails_for_each_user(self): - user_with_email_sent = UserFactory() - user_with_multiple_emails = UserFactory() - user_with_no_emails_sent = UserFactory() - time = timezone.now() - timedelta(days=1) - mail_sent = QueuedMail( - user=user_with_email_sent, - send_at=time, - to_addr=user_with_email_sent.username, - email_type=NO_LOGIN_TYPE - ) - mail_sent.save() - mail1 = self.queue_mail(user=user_with_email_sent) - mail2 = self.queue_mail(user=user_with_multiple_emails) - mail3 = self.queue_mail(user=user_with_multiple_emails) - mail4 = self.queue_mail(user=user_with_no_emails_sent) - user_queue = { - user_with_email_sent._id: [mail1], - user_with_multiple_emails._id: [mail2, mail3], - user_with_no_emails_sent._id: [mail4] - } - mails_ = list(pop_and_verify_mails_for_each_user(user_queue)) - assert len(mails_) == 2 - user_mails = [mail.user for mail in mails_] - assert not (user_with_email_sent in user_mails) - assert user_with_multiple_emails in user_mails - assert user_with_no_emails_sent in user_mails - - def test_find_queued_mails_ready_to_be_sent(self): - mail1 = self.queue_mail() - mail2 = self.queue_mail(send_at=timezone.now()+timedelta(days=1)) - mail3 = self.queue_mail(send_at=timezone.now()) - mails = find_queued_mails_ready_to_be_sent() - assert mails.count() == 2 diff --git a/scripts/triggered_mails.py b/scripts/triggered_mails.py index 3e0c4fea73a..6afd5ae9129 100644 --- a/scripts/triggered_mails.py +++ b/scripts/triggered_mails.py @@ -1,48 +1,157 @@ import logging +import uuid +from django.core.mail import send_mail from django.db import transaction -from django.db.models import Q +from django.db.models import Q, Exists, OuterRef from django.utils import timezone from framework.celery_tasks import app as celery_app from osf.models import OSFUser -from osf.models.queued_mail import NO_LOGIN_TYPE, NO_LOGIN, QueuedMail, queue_mail from website.app import init_app from website import settings +from osf.models import EmailTask # <-- new + from scripts.utils import add_file_logger logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) +NO_LOGIN_PREFIX = 'no_login:' # used to namespace this email type in task_id + + +def main(dry_run: bool = True): + users = find_inactive_users_without_enqueued_or_sent_no_login() + if not users.exists(): + logger.info('No users matched inactivity criteria.') + return -def main(dry_run=True): - for user in find_inactive_users_with_no_inactivity_email_sent_or_queued(): + for user in users.iterator(): if dry_run: logger.warning('Dry run mode') - logger.warning(f'Email of type no_login queued to {user.username}') - if not dry_run: - with transaction.atomic(): - queue_mail( - to_addr=user.username, - mail=NO_LOGIN, - send_at=timezone.now(), - user=user, - fullname=user.fullname, - osf_support_email=settings.OSF_SUPPORT_EMAIL, - ) - - -def find_inactive_users_with_no_inactivity_email_sent_or_queued(): - users_sent_ids = QueuedMail.objects.filter(email_type=NO_LOGIN_TYPE).values_list('user__guids___id') - return (OSFUser.objects - .filter( - (Q(date_last_login__lt=timezone.now() - settings.NO_LOGIN_WAIT_TIME) & ~Q(tags__name='osf4m')) | - Q(date_last_login__lt=timezone.now() - settings.NO_LOGIN_OSF4M_WAIT_TIME, tags__name='osf4m'), - is_active=True) - .exclude(guids___id__in=users_sent_ids)) - -@celery_app.task(name='scripts.triggered_mails') + logger.warning(f'[DRY RUN] Would enqueue no_login email for {user.username}') + continue + + with transaction.atomic(): + # Create the EmailTask row first (status=PENDING) + task_id = f'{NO_LOGIN_PREFIX}{uuid.uuid4()}' + email_task = EmailTask.objects.create( + task_id=task_id, + user=user, + status='PENDING', + ) + logger.info(f'Queued EmailTask {email_task.task_id} for user {user.username}') + + # Kick off the Celery task with the EmailTask PK + send_no_login_email.delay(email_task_id=email_task.id) + + +def find_inactive_users_without_enqueued_or_sent_no_login(): + """ + Match your original inactivity rules, but exclude users who already have a no_login EmailTask + either pending, started, retrying, or already sent successfully. + """ + + # Subquery: Is there already a not-yet-failed/aborted EmailTask for this user with our prefix? + existing_no_login = EmailTask.objects.filter( + user_id=OuterRef('pk'), + task_id__startswith=NO_LOGIN_PREFIX, + status__in=['PENDING', 'STARTED', 'RETRY', 'SUCCESS'], + ) + + base_q = OSFUser.objects.filter(is_active=True).filter( + Q( + date_last_login__lt=timezone.now() - settings.NO_LOGIN_WAIT_TIME, + # NOT tagged osf4m + ) & ~Q(tags__name='osf4m') + | + Q( + date_last_login__lt=timezone.now() - settings.NO_LOGIN_OSF4M_WAIT_TIME, + tags__name='osf4m' + ) + ) + + # Exclude users who already have a task for this email type + return base_q.annotate(_has_task=Exists(existing_no_login)).filter(_has_task=False) + + +@celery_app.task(name='scripts.triggered_no_login_email') +def send_no_login_email(email_task_id: int): + """ + Worker that sends the no-login email and updates EmailTask.status accordingly. + """ + + # Late import to avoid app registry issues in Celery + from osf.models import EmailTask + + try: + email_task = EmailTask.objects.select_related('user').get(id=email_task_id) + except EmailTask.DoesNotExist: + logger.error(f'EmailTask {email_task_id} not found') + return + + # If this task already reached a terminal state, don't send again (idempotent) + if email_task.status in ['SUCCESS']: + logger.info(f'EmailTask {email_task.id} already SUCCESS; skipping') + return + + # Update to STARTED + EmailTask.objects.filter(id=email_task.id).update(status='STARTED') + + try: + user = email_task.user + if user is None: + EmailTask.objects.filter(id=email_task.id).update(status='NO_USER_FOUND') + logger.warning(f'EmailTask {email_task.id}: no associated user') + return + + if not user.is_active: + EmailTask.objects.filter(id=email_task.id).update(status='USER_DISABLED') + logger.warning(f'EmailTask {email_task.id}: user {user.id} is not active') + return + + # --- Send the email --- + # Replace this with your real templated email system if desired. + subject = 'We miss you at OSF' + message = ( + f'Hello {user.fullname},\n\n' + 'We noticed you haven’t logged into OSF in a while. ' + 'Your projects, registrations, and files are still here whenever you need them.\n\n' + f'If you need help, contact us at {settings.OSF_SUPPORT_EMAIL}.\n\n' + '— OSF Team' + ) + from_email = settings.OSF_SUPPORT_EMAIL + recipient_list = [user.username] # assuming username is the email address + + # If you want HTML email or a template, swap in EmailMultiAlternatives and render_to_string. + sent_count = send_mail( + subject=subject, + message=message, + from_email=from_email, + recipient_list=recipient_list, + fail_silently=False, + ) + + if sent_count > 0: + EmailTask.objects.filter(id=email_task.id).update(status='SUCCESS') + logger.info(f'EmailTask {email_task.id}: email sent to {user.username}') + else: + EmailTask.objects.filter(id=email_task.id).update( + status='FAILURE', + error_message='send_mail returned 0' + ) + logger.error(f'EmailTask {email_task.id}: send_mail returned 0') + + except Exception as exc: # noqa: BLE001 + logger.exception(f'EmailTask {email_task.id}: error while sending') + EmailTask.objects.filter(id=email_task.id).update( + status='FAILURE', + error_message=str(exc) + ) + + +@celery_app.task(name='scripts.triggered_mails') # keep the original entry point for compatibility def run_main(dry_run=True): init_app(routes=False) if not dry_run: diff --git a/tests/base.py b/tests/base.py index 2c36dd801eb..98065c37e83 100644 --- a/tests/base.py +++ b/tests/base.py @@ -21,10 +21,6 @@ from osf.models import RegistrationSchema from website import settings from website.app import init_app -from website.notifications.listeners import (subscribe_contributor, - subscribe_creator) -from website.project.signals import contributor_added, project_created -from website.project.views.contributor import notify_added_contributor from website.signals import ALL_SIGNALS from .json_api_test_app import JSONAPITestApp @@ -57,8 +53,6 @@ def get_default_metaschema(): 'framework.auth.core', 'website.app', 'website.archiver.tasks', - 'website.mails', - 'website.notifications.listeners', 'website.search.elastic_search', 'website.search_migration.migrate', 'website.util.paths', @@ -72,7 +66,6 @@ def get_default_metaschema(): # Fake factory fake = Factory.create() - @pytest.mark.django_db class DbTestCase(unittest.TestCase): """Base `TestCase` for tests that require a scratch database. @@ -94,15 +87,12 @@ def tearDownClass(cls): settings.BCRYPT_LOG_ROUNDS = cls._original_bcrypt_log_rounds + class AppTestCase(unittest.TestCase): """Base `TestCase` for OSF tests that require the WSGI app (but no database). """ PUSH_CONTEXT = True - DISCONNECTED_SIGNALS = { - # disconnect notify_add_contributor so that add_contributor does not send "fake" emails in tests - contributor_added: [notify_added_contributor] - } def setUp(self): super().setUp() @@ -122,9 +112,6 @@ def setUp(self): self.context.push() with self.context: celery_before_request() - for signal in self.DISCONNECTED_SIGNALS: - for receiver in self.DISCONNECTED_SIGNALS[signal]: - signal.disconnect(receiver) def tearDown(self): super().tearDown() @@ -132,9 +119,6 @@ def tearDown(self): return with mock.patch('website.mailchimp_utils.get_mailchimp_api'): self.context.pop() - for signal in self.DISCONNECTED_SIGNALS: - for receiver in self.DISCONNECTED_SIGNALS[signal]: - signal.connect(receiver) class ApiAppTestCase(unittest.TestCase): @@ -177,7 +161,7 @@ class OsfTestCase(DbTestCase, AppTestCase, SearchTestCase): application. Note: superclasses must call `super` in order for all setup and teardown methods to be called correctly. """ - pass + class ApiTestCase(DbTestCase, ApiAppTestCase, SearchTestCase): @@ -185,9 +169,6 @@ class ApiTestCase(DbTestCase, ApiAppTestCase, SearchTestCase): API application. Note: superclasses must call `super` in order for all setup and teardown methods to be called correctly. """ - def setUp(self): - super().setUp() - settings.USE_EMAIL = False class ApiAddonTestCase(ApiTestCase): """Base `TestCase` for tests that require interaction with addons. @@ -273,24 +254,6 @@ class AdminTestCase(DbTestCase, DjangoTestCase, SearchTestCase): pass -class NotificationTestCase(OsfTestCase): - """An `OsfTestCase` to use when testing specific subscription behavior. - Use when you'd like to manually create all Node subscriptions and subscriptions - for added contributors yourself, and not rely on automatically added ones. - """ - DISCONNECTED_SIGNALS = { - # disconnect signals so that add_contributor does not send "fake" emails in tests - contributor_added: [notify_added_contributor, subscribe_contributor], - project_created: [subscribe_creator] - } - - def setUp(self): - super().setUp() - - def tearDown(self): - super().tearDown() - - class ApiWikiTestCase(ApiTestCase): def setUp(self): diff --git a/tests/framework_tests/test_email.py b/tests/framework_tests/test_email.py deleted file mode 100644 index 5e2216fc7bc..00000000000 --- a/tests/framework_tests/test_email.py +++ /dev/null @@ -1,121 +0,0 @@ -import unittest -import smtplib - -from unittest import mock -from unittest.mock import MagicMock - -import sendgrid -from sendgrid import SendGridAPIClient -from sendgrid.helpers.mail import Mail, Email, To, Category - -from framework.email.tasks import send_email, _send_with_sendgrid -from website import settings -from tests.base import fake -from osf_tests.factories import fake_email - -# Check if local mail server is running -SERVER_RUNNING = True -try: - s = smtplib.SMTP(settings.MAIL_SERVER) - s.quit() -except Exception as err: - SERVER_RUNNING = False - - -class TestEmail(unittest.TestCase): - - @unittest.skipIf(not SERVER_RUNNING, - "Mailserver isn't running. Run \"invoke mailserver\".") - @unittest.skipIf(not settings.USE_EMAIL, - 'settings.USE_EMAIL is False') - def test_sending_email(self): - assert send_email('foo@bar.com', 'baz@quux.com', subject='no subject', - message='

Greetings!

', ttls=False, login=False) - - def setUp(self): - settings.SENDGRID_WHITELIST_MODE = False - - def tearDown(self): - settings.SENDGRID_WHITELIST_MODE = True - - @mock.patch(f'{_send_with_sendgrid.__module__}.Mail', autospec=True) - def test_send_with_sendgrid_success(self, mock_mail: MagicMock): - mock_client = mock.MagicMock(autospec=SendGridAPIClient) - mock_client.send.return_value = mock.Mock(status_code=200, body='success') - from_addr, to_addr = fake_email(), fake_email() - category1, category2 = fake.word(), fake.word() - subject = fake.bs() - message = fake.text() - ret = _send_with_sendgrid( - from_addr=from_addr, - to_addr=to_addr, - subject=subject, - message=message, - client=mock_client, - categories=(category1, category2) - ) - assert ret - - # Check Mail object arguments - mock_mail.assert_called_once() - kwargs = mock_mail.call_args.kwargs - assert kwargs['from_email'].email == from_addr - assert kwargs['subject'] == subject - assert kwargs['html_content'] == message - - mock_mail.return_value.add_personalization.assert_called_once() - - # Capture the categories added via add_category - # mock_mail.return_value.add_category.assert_called_once() - assert mock_mail.return_value.add_category.call_count == 2 - added_categories = mock_mail.return_value.add_category.call_args_list - assert len(added_categories) == 2 - assert isinstance(added_categories[0].args[0], Category) - assert isinstance(added_categories[1].args[0], Category) - assert added_categories[0].args[0].get() == category1 - assert added_categories[1].args[0].get() == category2 - - mock_client.send.assert_called_once_with(mock_mail.return_value) - - @mock.patch(f'{_send_with_sendgrid.__module__}.sentry.log_message', autospec=True) - @mock.patch(f'{_send_with_sendgrid.__module__}.Mail', autospec=True) - def test_send_with_sendgrid_failure_returns_false(self, mock_mail, sentry_mock): - mock_client = mock.MagicMock() - mock_client.send.return_value = mock.Mock(status_code=400, body='failed') - from_addr, to_addr = fake_email(), fake_email() - subject = fake.bs() - message = fake.text() - ret = _send_with_sendgrid( - from_addr=from_addr, - to_addr=to_addr, - subject=subject, - message=message, - client=mock_client - ) - assert not ret - sentry_mock.assert_called_once() - - # Check Mail object arguments - mock_mail.assert_called_once() - kwargs = mock_mail.call_args.kwargs - assert kwargs['from_email'].email == from_addr - assert kwargs['subject'] == subject - assert kwargs['html_content'] == message - - mock_client.send.assert_called_once_with(mock_mail.return_value) - - mock_client.send.return_value = mock.Mock(status_code=200, body='correct') - to_addr = 'not-an-email' - ret = _send_with_sendgrid( - from_addr=from_addr, - to_addr=to_addr, - subject=subject, - message=message, - client=mock_client - ) - assert not ret - sentry_mock.assert_called() - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/identifiers/test_crossref.py b/tests/identifiers/test_crossref.py index 58e169464c9..e05284f303e 100644 --- a/tests/identifiers/test_crossref.py +++ b/tests/identifiers/test_crossref.py @@ -4,6 +4,7 @@ import pytest import responses +from tests.utils import capture_notifications from website import settings from website.identifiers.clients import crossref @@ -42,7 +43,8 @@ def preprint(): @pytest.fixture() def preprint_version(preprint): - versioned_preprint = PreprintFactory.create_version(preprint) + with capture_notifications(): + versioned_preprint = PreprintFactory.create_version(preprint) return versioned_preprint @pytest.fixture() diff --git a/tests/identifiers/test_datacite.py b/tests/identifiers/test_datacite.py index 768a400fc59..f601d1b6f1e 100644 --- a/tests/identifiers/test_datacite.py +++ b/tests/identifiers/test_datacite.py @@ -1,7 +1,6 @@ import lxml import pytest import responses -from unittest import mock from datacite import schema40 from django.utils import timezone @@ -29,7 +28,6 @@ def _assert_unordered_list_of_dicts_equal(actual_list_of_dicts, expected_list_of @pytest.mark.django_db @pytest.mark.usefixtures('mock_gravy_valet_get_verified_links') -@mock.patch('website.mails.settings.USE_EMAIL', False) class TestDataCiteClient: @pytest.fixture() @@ -155,7 +153,7 @@ def test_datcite_format_contributors(self, datacite_client): assert f'{invisible_contrib.fullname}' not in metadata_xml def test_datacite_format_related_resources(self, datacite_client): - registration = RegistrationFactory(is_public=True, has_doi=True, article_doi='publication') + registration = RegistrationFactory(is_public=True, has_doi=True, article_doi='10.31219/FK2osf.io/test!') outcome = Outcome.objects.for_registration(registration, create=True) data_artifact = outcome.artifact_metadata.create( identifier=IdentifierFactory(category='doi'), artifact_type=ArtifactTypes.DATA, finalized=True @@ -179,7 +177,7 @@ def test_datacite_format_related_resources(self, datacite_client): 'relationType': 'References', }, { - 'relatedIdentifier': 'publication', + 'relatedIdentifier': '10.31219/FK2osf.io/test!', 'relatedIdentifierType': 'DOI', 'relationType': 'References', }, diff --git a/tests/test_adding_contributor_views.py b/tests/test_adding_contributor_views.py index 17c2da39bc3..72fe3ee10c4 100644 --- a/tests/test_adding_contributor_views.py +++ b/tests/test_adding_contributor_views.py @@ -1,32 +1,19 @@ - -from unittest.mock import ANY - import time -from http.cookies import SimpleCookie from unittest import mock import pytest from django.core.exceptions import ValidationError -from flask import g -from pytest import approx from rest_framework import status as http_status from framework import auth -from framework.auth import Auth, authenticate, cas -from framework.auth.utils import impute_names_model +from framework.auth import Auth from framework.exceptions import HTTPError -from framework.flask import redirect -from osf.models import ( - OSFUser, - Tag, - NodeRelation, -) +from osf.models import NodeRelation, NotificationType from osf.utils import permissions from osf_tests.factories import ( fake_email, AuthUserFactory, NodeFactory, - PreprintFactory, ProjectFactory, RegistrationProviderFactory, UserFactory, @@ -38,22 +25,15 @@ get_default_metaschema, OsfTestCase, ) -from tests.test_cas_authentication import generate_external_user_with_resp -from website import mails, settings +from tests.utils import capture_notifications from website.profile.utils import add_contributor_json, serialize_unregistered -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.util.metrics import OsfSourceTags, OsfClaimedTags, provider_source_tag, provider_claimed_tag -from conftest import start_mock_send_grid @pytest.mark.enable_implicit_clean -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestAddingContributorViews(OsfTestCase): def setUp(self): @@ -61,10 +41,6 @@ def setUp(self): self.creator = AuthUserFactory() self.project = ProjectFactory(creator=self.creator) self.auth = Auth(self.project.creator) - # Authenticate all requests - contributor_added.connect(notify_added_contributor) - - self.mock_send_grid = start_mock_send_grid(self) def test_serialize_unregistered_without_record(self): name, email = fake.name(), fake_email() @@ -171,7 +147,8 @@ def test_add_contributor_with_unreg_contribs_and_reg_contribs(self): 'node_ids': [] } url = self.project.api_url_for('project_contributors_post') - self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) + with capture_notifications(): + self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) self.project.reload() assert len(self.project.contributors) == n_contributors_pre + len(payload['users']) @@ -185,11 +162,10 @@ def test_add_contributor_with_unreg_contribs_and_reg_contribs(self): assert rec['email'] == email @mock.patch('website.project.views.contributor.send_claim_email') - def test_add_contributors_post_only_sends_one_email_to_unreg_user( - self, mock_send_claim_email): + def test_add_contributors_post_only_sends_one_email_to_unreg_user(self, mock_send_claim_email): # Project has components - comp1, comp2 = NodeFactory( - creator=self.creator), NodeFactory(creator=self.creator) + comp1 = NodeFactory(creator=self.creator) + comp2 = NodeFactory(creator=self.creator) NodeRelation.objects.create(parent=self.project, child=comp1) NodeRelation.objects.create(parent=self.project, child=comp2) self.project.save() @@ -211,10 +187,10 @@ def test_add_contributors_post_only_sends_one_email_to_unreg_user( # send request url = self.project.api_url_for('project_contributors_post') assert self.project.can_edit(user=self.creator) - self.app.post(url, json=payload, auth=self.creator.auth) - - # finalize_invitation should only have been called once - assert mock_send_claim_email.call_count == 1 + with capture_notifications() as notifications: + self.app.post(url, json=payload, auth=self.creator.auth) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT def test_add_contributors_post_only_sends_one_email_to_registered_user(self): # Project has components @@ -238,10 +214,10 @@ def test_add_contributors_post_only_sends_one_email_to_registered_user(self): # send request url = self.project.api_url_for('project_contributors_post') assert self.project.can_edit(user=self.creator) - self.app.post(url, json=payload, auth=self.creator.auth) - - # send_mail should only have been called once - assert self.mock_send_grid.call_count == 1 + with capture_notifications() as notifications: + self.app.post(url, json=payload, auth=self.creator.auth) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT def test_add_contributors_post_sends_email_if_user_not_contributor_on_parent_node(self): # Project has a component with a sub-component @@ -265,10 +241,12 @@ def test_add_contributors_post_sends_email_if_user_not_contributor_on_parent_nod # send request url = self.project.api_url_for('project_contributors_post') assert self.project.can_edit(user=self.creator) - self.app.post(url, json=payload, auth=self.creator.auth) + with capture_notifications() as notifications: + self.app.post(url, json=payload, auth=self.creator.auth) # send_mail is called for both the project and the sub-component - assert self.mock_send_grid.call_count == 2 + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT @mock.patch('website.project.views.contributor.send_claim_email') def test_email_sent_when_unreg_user_is_added(self, send_mail): @@ -286,8 +264,10 @@ def test_email_sent_when_unreg_user_is_added(self, send_mail): 'node_ids': [] } url = self.project.api_url_for('project_contributors_post') - self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) - send_mail.assert_called_with(email, ANY,ANY,notify=True, email_template='default') + with capture_notifications() as notifications: + self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT def test_email_sent_when_reg_user_is_added(self): contributor = UserFactory() @@ -297,28 +277,27 @@ def test_email_sent_when_reg_user_is_added(self): 'permissions': permissions.WRITE }] project = ProjectFactory(creator=self.auth.user) - project.add_contributors(contributors, auth=self.auth) - project.save() - assert self.mock_send_grid.called - - assert contributor.contributor_added_email_records[project._id]['last_sent'] == approx(int(time.time()), rel=1) + with capture_notifications() as notifications: + project.add_contributors(contributors, auth=self.auth) + project.save() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT def test_contributor_added_email_sent_to_unreg_user(self): unreg_user = UnregUserFactory() project = ProjectFactory() project.add_unregistered_contributor(fullname=unreg_user.fullname, email=unreg_user.email, auth=Auth(project.creator)) project.save() - assert self.mock_send_grid.called def test_forking_project_does_not_send_contributor_added_email(self): project = ProjectFactory() - project.fork_node(auth=Auth(project.creator)) - assert not self.mock_send_grid.called + with capture_notifications(): + project.fork_node(auth=Auth(project.creator)) def test_templating_project_does_not_send_contributor_added_email(self): project = ProjectFactory() - project.use_as_template(auth=Auth(project.creator)) - assert not self.mock_send_grid.called + with capture_notifications(): + project.use_as_template(auth=Auth(project.creator)) @mock.patch('website.archiver.tasks.archive') def test_registering_project_does_not_send_contributor_added_email(self, mock_archive): @@ -331,57 +310,85 @@ def test_registering_project_does_not_send_contributor_added_email(self, mock_ar None, provider=provider ) - assert not self.mock_send_grid.called def test_notify_contributor_email_does_not_send_before_throttle_expires(self): contributor = UserFactory() project = ProjectFactory() auth = Auth(project.creator) - notify_added_contributor(project, contributor, auth) - assert self.mock_send_grid.called + with mock.patch('osf.email.send_email_with_send_grid', return_value=None): + with capture_notifications(passthrough=True) as notifications: + notify_added_contributor( + project, + contributor, + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT, + auth=auth + ) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT # 2nd call does not send email because throttle period has not expired - notify_added_contributor(project, contributor, auth) - assert self.mock_send_grid.call_count == 1 + notify_added_contributor( + project, + contributor, + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT, + auth=auth + ) def test_notify_contributor_email_sends_after_throttle_expires(self): - throttle = 0.5 - contributor = UserFactory() project = ProjectFactory() auth = Auth(project.creator) - notify_added_contributor(project, contributor, auth, throttle=throttle) - assert self.mock_send_grid.called - - time.sleep(1) # throttle period expires - notify_added_contributor(project, contributor, auth, throttle=throttle) - assert self.mock_send_grid.call_count == 2 + with capture_notifications() as notifications: + notify_added_contributor( + project, + contributor, + NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT, + auth, + ) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT + + time.sleep(2) # throttle period expires + with capture_notifications() as notifications: + notify_added_contributor( + project, + contributor, + NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT, + auth, + throttle=1 + ) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT def test_add_contributor_to_fork_sends_email(self): contributor = UserFactory() - fork = self.project.fork_node(auth=Auth(self.creator)) - fork.add_contributor(contributor, auth=Auth(self.creator)) - fork.save() - assert self.mock_send_grid.called - assert self.mock_send_grid.call_count == 1 + with capture_notifications() as notifications: + fork = self.project.fork_node(auth=Auth(self.creator)) + fork.add_contributor(contributor, auth=Auth(self.creator)) + fork.save() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT def test_add_contributor_to_template_sends_email(self): contributor = UserFactory() - template = self.project.use_as_template(auth=Auth(self.creator)) - template.add_contributor(contributor, auth=Auth(self.creator)) - template.save() - assert self.mock_send_grid.called - assert self.mock_send_grid.call_count == 1 + with capture_notifications() as notifications: + template = self.project.use_as_template(auth=Auth(self.creator)) + template.add_contributor( + contributor, + auth=Auth(self.creator), + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT + ) + template.save() + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_CONTRIBUTOR_ADDED_ACCESS_REQUEST def test_creating_fork_does_not_email_creator(self): - contributor = UserFactory() - fork = self.project.fork_node(auth=Auth(self.creator)) - assert not self.mock_send_grid.called + with capture_notifications(): + self.project.fork_node(auth=Auth(self.creator)) def test_creating_template_does_not_email_creator(self): - contributor = UserFactory() - template = self.project.use_as_template(auth=Auth(self.creator)) - assert not self.mock_send_grid.called + with capture_notifications(): + self.project.use_as_template(auth=Auth(self.creator)) def test_add_multiple_contributors_only_adds_one_log(self): n_logs_pre = self.project.logs.count() @@ -403,7 +410,8 @@ def test_add_multiple_contributors_only_adds_one_log(self): 'node_ids': [] } url = self.project.api_url_for('project_contributors_post') - self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) + with capture_notifications(): + self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) self.project.reload() assert self.project.logs.count() == n_logs_pre + 1 @@ -428,17 +436,12 @@ def test_add_contribs_to_multiple_nodes(self): 'node_ids': [self.project._primary_key, child._primary_key] } url = f'/api/v1/project/{self.project._id}/contributors/' - self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) + with capture_notifications(): + self.app.post(url, json=payload, follow_redirects=True, auth=self.creator.auth) child.reload() assert child.contributors.count() == n_contributors_pre + len(payload['users']) - def tearDown(self): - super().tearDown() - contributor_added.disconnect(notify_added_contributor) - -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestUserInviteViews(OsfTestCase): def setUp(self): @@ -447,8 +450,6 @@ def setUp(self): self.project = ProjectFactory(creator=self.user) self.invite_url = f'/api/v1/project/{self.project._primary_key}/invite_contributor/' - self.mock_send_grid = start_mock_send_grid(self) - def test_invite_contributor_post_if_not_in_db(self): name, email = fake.name(), fake_email() res = self.app.post( @@ -525,22 +526,27 @@ def test_send_claim_email_to_given_email(self): auth=Auth(project.creator), ) project.save() - send_claim_email(email=given_email, unclaimed_user=unreg_user, node=project) - - self.mock_send_grid.assert_called() + with capture_notifications() as notifications: + send_claim_email(email=given_email, unclaimed_user=unreg_user, node=project) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_INVITE_DEFAULT def test_send_claim_email_to_referrer(self): project = ProjectFactory() referrer = project.creator given_email, real_email = fake_email(), fake_email() - unreg_user = project.add_unregistered_contributor(fullname=fake.name(), - email=given_email, auth=Auth( - referrer) - ) + unreg_user = project.add_unregistered_contributor( + fullname=fake.name(), + email=given_email, + auth=Auth(referrer) + ) project.save() - send_claim_email(email=real_email, unclaimed_user=unreg_user, node=project) + with capture_notifications() as notifications: + send_claim_email(email=real_email, unclaimed_user=unreg_user, node=project) - assert self.mock_send_grid.called + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_PENDING_VERIFICATION + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_FORWARD_INVITE def test_send_claim_email_before_throttle_expires(self): project = ProjectFactory() @@ -551,447 +557,9 @@ def test_send_claim_email_before_throttle_expires(self): auth=Auth(project.creator), ) project.save() - send_claim_email(email=fake_email(), unclaimed_user=unreg_user, node=project) - self.mock_send_grid.reset_mock() - # 2nd call raises error because throttle hasn't expired - with pytest.raises(HTTPError): + with capture_notifications(): send_claim_email(email=fake_email(), unclaimed_user=unreg_user, node=project) - assert not self.mock_send_grid.called - - -@pytest.mark.enable_implicit_clean -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) -class TestClaimViews(OsfTestCase): - - def setUp(self): - super().setUp() - self.referrer = AuthUserFactory() - self.project = ProjectFactory(creator=self.referrer, is_public=True) - self.project_with_source_tag = ProjectFactory(creator=self.referrer, is_public=True) - self.preprint_with_source_tag = PreprintFactory(creator=self.referrer, is_public=True) - osf_source_tag, created = Tag.all_tags.get_or_create(name=OsfSourceTags.Osf.value, system=True) - preprint_source_tag, created = Tag.all_tags.get_or_create(name=provider_source_tag(self.preprint_with_source_tag.provider._id, 'preprint'), system=True) - self.project_with_source_tag.add_system_tag(osf_source_tag.name) - self.preprint_with_source_tag.add_system_tag(preprint_source_tag.name) - self.given_name = fake.name() - self.given_email = fake_email() - self.project_with_source_tag.add_unregistered_contributor( - fullname=self.given_name, - email=self.given_email, - auth=Auth(user=self.referrer) - ) - self.preprint_with_source_tag.add_unregistered_contributor( - fullname=self.given_name, - email=self.given_email, - auth=Auth(user=self.referrer) - ) - self.user = self.project.add_unregistered_contributor( - fullname=self.given_name, - email=self.given_email, - auth=Auth(user=self.referrer) - ) - self.project.save() - - self.mock_send_grid = start_mock_send_grid(self) - - @mock.patch('website.project.views.contributor.send_claim_email') - def test_claim_user_already_registered_redirects_to_claim_user_registered(self, claim_email): - name = fake.name() - email = fake_email() - - # project contributor adds an unregistered contributor (without an email) on public project - unregistered_user = self.project.add_unregistered_contributor( - fullname=name, - email=None, - auth=Auth(user=self.referrer) - ) - assert unregistered_user in self.project.contributors - - # unregistered user comes along and claims themselves on the public project, entering an email - invite_url = self.project.api_url_for('claim_user_post', uid='undefined') - self.app.post(invite_url, json={ - 'pk': unregistered_user._primary_key, - 'value': email - }) - assert claim_email.call_count == 1 - - # set unregistered record email since we are mocking send_claim_email() - unclaimed_record = unregistered_user.get_unclaimed_record(self.project._primary_key) - unclaimed_record.update({'email': email}) - unregistered_user.save() - - # unregistered user then goes and makes an account with same email, before claiming themselves as contributor - UserFactory(username=email, fullname=name) - - # claim link for the now registered email is accessed while not logged in - token = unregistered_user.get_unclaimed_record(self.project._primary_key)['token'] - claim_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/?token={token}' - res = self.app.get(claim_url) - - # should redirect to 'claim_user_registered' view - claim_registered_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/verify/{token}/' - assert res.status_code == 302 - assert claim_registered_url in res.headers.get('Location') - - @mock.patch('website.project.views.contributor.send_claim_email') - def test_claim_user_already_registered_secondary_email_redirects_to_claim_user_registered(self, claim_email): - name = fake.name() - email = fake_email() - secondary_email = fake_email() - - # project contributor adds an unregistered contributor (without an email) on public project - unregistered_user = self.project.add_unregistered_contributor( - fullname=name, - email=None, - auth=Auth(user=self.referrer) - ) - assert unregistered_user in self.project.contributors - - # unregistered user comes along and claims themselves on the public project, entering an email - invite_url = self.project.api_url_for('claim_user_post', uid='undefined') - self.app.post(invite_url, json={ - 'pk': unregistered_user._primary_key, - 'value': secondary_email - }) - assert claim_email.call_count == 1 - - # set unregistered record email since we are mocking send_claim_email() - unclaimed_record = unregistered_user.get_unclaimed_record(self.project._primary_key) - unclaimed_record.update({'email': secondary_email}) - unregistered_user.save() - - # unregistered user then goes and makes an account with same email, before claiming themselves as contributor - registered_user = UserFactory(username=email, fullname=name) - registered_user.emails.create(address=secondary_email) - registered_user.save() - - # claim link for the now registered email is accessed while not logged in - token = unregistered_user.get_unclaimed_record(self.project._primary_key)['token'] - claim_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/?token={token}' - res = self.app.get(claim_url) - - # should redirect to 'claim_user_registered' view - claim_registered_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/verify/{token}/' - assert res.status_code == 302 - assert claim_registered_url in res.headers.get('Location') - - def test_claim_user_invited_with_no_email_posts_to_claim_form(self): - given_name = fake.name() - invited_user = self.project.add_unregistered_contributor( - fullname=given_name, - email=None, - auth=Auth(user=self.referrer) - ) - self.project.save() - - url = invited_user.get_claim_url(self.project._primary_key) - res = self.app.post(url, data={ - 'password': 'bohemianrhap', - 'password2': 'bohemianrhap' - }) - assert res.status_code == 400 - - def test_claim_user_post_with_registered_user_id(self): - # registered user who is attempting to claim the unclaimed contributor - reg_user = UserFactory() - payload = { - # pk of unreg user record - 'pk': self.user._primary_key, - 'claimerId': reg_user._primary_key - } - url = f'/api/v1/user/{self.user._primary_key}/{self.project._primary_key}/claim/email/' - res = self.app.post(url, json=payload) - - # mail was sent - assert self.mock_send_grid.call_count == 2 - # ... to the correct address - referrer_call = self.mock_send_grid.call_args_list[0] - claimer_call = self.mock_send_grid.call_args_list[1] - - assert referrer_call[1]['to_addr'] == self.referrer.email - assert claimer_call[1]['to_addr'] == reg_user.email - - # view returns the correct JSON - assert res.json == { - 'status': 'success', - 'email': reg_user.username, - 'fullname': self.given_name, - } - - def test_send_claim_registered_email(self): - reg_user = UserFactory() - send_claim_registered_email( - claimer=reg_user, - unclaimed_user=self.user, - node=self.project - ) - assert self.mock_send_grid.call_count == 2 - first_call_args = self.mock_send_grid.call_args_list[0][1] - assert first_call_args['to_addr'] == self.referrer.email - second_call_args = self.mock_send_grid.call_args_list[1][1] - assert second_call_args['to_addr'] == reg_user.email + # 2nd call raises error because throttle hasn't expired - def test_send_claim_registered_email_before_throttle_expires(self): - reg_user = UserFactory() - send_claim_registered_email( - claimer=reg_user, - unclaimed_user=self.user, - node=self.project, - ) - self.mock_send_grid.reset_mock() - # second call raises error because it was called before throttle period with pytest.raises(HTTPError): - send_claim_registered_email( - claimer=reg_user, - unclaimed_user=self.user, - node=self.project, - ) - assert not self.mock_send_grid.called - - @mock.patch('website.project.views.contributor.send_claim_registered_email') - def test_claim_user_post_with_email_already_registered_sends_correct_email( - self, send_claim_registered_email): - reg_user = UserFactory() - payload = { - 'value': reg_user.username, - 'pk': self.user._primary_key - } - url = self.project.api_url_for('claim_user_post', uid=self.user._id) - self.app.post(url, json=payload) - assert send_claim_registered_email.called - - def test_user_with_removed_unclaimed_url_claiming(self): - """ Tests that when an unclaimed user is removed from a project, the - unregistered user object does not retain the token. - """ - self.project.remove_contributor(self.user, Auth(user=self.referrer)) - - assert self.project._primary_key not in self.user.unclaimed_records.keys() - - def test_user_with_claim_url_cannot_claim_twice(self): - """ Tests that when an unclaimed user is replaced on a project with a - claimed user, the unregistered user object does not retain the token. - """ - reg_user = AuthUserFactory() - - self.project.replace_contributor(self.user, reg_user) - - assert self.project._primary_key not in self.user.unclaimed_records.keys() - - def test_claim_user_form_redirects_to_password_confirm_page_if_user_is_logged_in(self): - reg_user = AuthUserFactory() - url = self.user.get_claim_url(self.project._primary_key) - res = self.app.get(url, auth=reg_user.auth) - assert res.status_code == 302 - res = self.app.get(url, auth=reg_user.auth, follow_redirects=True) - token = self.user.get_unclaimed_record(self.project._primary_key)['token'] - expected = self.project.web_url_for( - 'claim_user_registered', - uid=self.user._id, - token=token, - ) - assert res.request.path == expected - - @mock.patch('framework.auth.cas.make_response_from_ticket') - def test_claim_user_when_user_is_registered_with_orcid(self, mock_response_from_ticket): - # TODO: check in qa url encoding - token = self.user.get_unclaimed_record(self.project._primary_key)['token'] - url = f'/user/{self.user._id}/{self.project._id}/claim/verify/{token}/' - # logged out user gets redirected to cas login - res1 = self.app.get(url) - assert res1.status_code == 302 - res = self.app.resolve_redirect(self.app.get(url)) - service_url = f'http://localhost{url}' - expected = cas.get_logout_url(service_url=cas.get_login_url(service_url=service_url)) - assert res1.location == expected - - # user logged in with orcid automatically becomes a contributor - orcid_user, validated_credentials, cas_resp = generate_external_user_with_resp(url) - mock_response_from_ticket.return_value = authenticate( - orcid_user, - redirect(url) - ) - orcid_user.set_unusable_password() - orcid_user.save() - - # The request to OSF with CAS service ticket must not have cookie and/or auth. - service_ticket = fake.md5() - url_with_service_ticket = f'{url}?ticket={service_ticket}' - res = self.app.get(url_with_service_ticket) - # The response of this request is expected to be a 302 with `Location`. - # And the redirect URL must equal to the originial service URL - assert res.status_code == 302 - redirect_url = res.headers['Location'] - assert redirect_url == url - # The response of this request is expected have the `Set-Cookie` header with OSF cookie. - # And the cookie must belong to the ORCiD user. - raw_set_cookie = res.headers['Set-Cookie'] - assert raw_set_cookie - simple_cookie = SimpleCookie() - simple_cookie.load(raw_set_cookie) - cookie_dict = {key: value.value for key, value in simple_cookie.items()} - osf_cookie = cookie_dict.get(settings.COOKIE_NAME, None) - assert osf_cookie is not None - user = OSFUser.from_cookie(osf_cookie) - assert user._id == orcid_user._id - # The ORCiD user must be different from the unregistered user created when the contributor was added - assert user._id != self.user._id - - # Must clear the Flask g context manual and set the OSF cookie to context - g.current_session = None - self.app.set_cookie(settings.COOKIE_NAME, osf_cookie) - res = self.app.resolve_redirect(res) - assert res.status_code == 302 - assert self.project.is_contributor(orcid_user) - assert self.project.url in res.headers.get('Location') - - def test_get_valid_form(self): - url = self.user.get_claim_url(self.project._primary_key) - res = self.app.get(url, follow_redirects=True) - assert res.status_code == 200 - - def test_invalid_claim_form_raise_400(self): - uid = self.user._primary_key - pid = self.project._primary_key - url = f'/user/{uid}/{pid}/claim/?token=badtoken' - res = self.app.get(url, follow_redirects=True) - assert res.status_code == 400 - - @mock.patch('osf.models.OSFUser.update_search_nodes') - def test_posting_to_claim_form_with_valid_data(self, mock_update_search_nodes): - url = self.user.get_claim_url(self.project._primary_key) - res = self.app.post(url, data={ - 'username': self.user.username, - 'password': 'killerqueen', - 'password2': 'killerqueen' - }) - - assert res.status_code == 302 - location = res.headers.get('Location') - assert 'login?service=' in location - assert 'username' in location - assert 'verification_key' in location - assert self.project._primary_key in location - - self.user.reload() - assert self.user.is_registered - assert self.user.is_active - assert self.project._primary_key not in self.user.unclaimed_records - - @mock.patch('osf.models.OSFUser.update_search_nodes') - def test_posting_to_claim_form_removes_all_unclaimed_data(self, mock_update_search_nodes): - # user has multiple unclaimed records - p2 = ProjectFactory(creator=self.referrer) - self.user.add_unclaimed_record(p2, referrer=self.referrer, - given_name=fake.name()) - self.user.save() - assert len(self.user.unclaimed_records.keys()) > 1 # sanity check - url = self.user.get_claim_url(self.project._primary_key) - res = self.app.post(url, data={ - 'username': self.given_email, - 'password': 'bohemianrhap', - 'password2': 'bohemianrhap' - }) - self.user.reload() - assert self.user.unclaimed_records == {} - - @mock.patch('osf.models.OSFUser.update_search_nodes') - def test_posting_to_claim_form_sets_fullname_to_given_name(self, mock_update_search_nodes): - # User is created with a full name - original_name = fake.name() - unreg = UnregUserFactory(fullname=original_name) - # User invited with a different name - different_name = fake.name() - new_user = self.project.add_unregistered_contributor( - email=unreg.username, - fullname=different_name, - auth=Auth(self.project.creator), - ) - self.project.save() - # Goes to claim url - claim_url = new_user.get_claim_url(self.project._id) - self.app.post(claim_url, data={ - 'username': unreg.username, - 'password': 'killerqueen', - 'password2': 'killerqueen' - }) - unreg.reload() - # Full name was set correctly - assert unreg.fullname == different_name - # CSL names were set correctly - parsed_name = impute_names_model(different_name) - assert unreg.given_name == parsed_name['given_name'] - assert unreg.family_name == parsed_name['family_name'] - - def test_claim_user_post_returns_fullname(self): - url = f'/api/v1/user/{self.user._primary_key}/{self.project._primary_key}/claim/email/' - res = self.app.post( - url, - auth=self.referrer.auth, - json={ - 'value': self.given_email, - 'pk': self.user._primary_key - }, - ) - assert res.json['fullname'] == self.given_name - assert self.mock_send_grid.called - - def test_claim_user_post_if_email_is_different_from_given_email(self): - email = fake_email() # email that is different from the one the referrer gave - url = f'/api/v1/user/{self.user._primary_key}/{self.project._primary_key}/claim/email/' - self.app.post(url, json={'value': email, 'pk': self.user._primary_key} ) - assert self.mock_send_grid.called - assert self.mock_send_grid.call_count == 2 - call_to_invited = self.mock_send_grid.mock_calls[0] - call_to_invited.assert_called_with(to_addr=email) - call_to_referrer = self.mock_send_grid.mock_calls[1] - call_to_referrer.assert_called_with(to_addr=self.given_email) - - def test_claim_url_with_bad_token_returns_400(self): - url = self.project.web_url_for( - 'claim_user_registered', - uid=self.user._id, - token='badtoken', - ) - res = self.app.get(url, auth=self.referrer.auth) - assert res.status_code == 400 - - def test_cannot_claim_user_with_user_who_is_already_contributor(self): - # user who is already a contirbutor to the project - contrib = AuthUserFactory() - self.project.add_contributor(contrib, auth=Auth(self.project.creator)) - self.project.save() - # Claiming user goes to claim url, but contrib is already logged in - url = self.user.get_claim_url(self.project._primary_key) - res = self.app.get( - url, - auth=contrib.auth, follow_redirects=True) - # Response is a 400 - assert res.status_code == 400 - - def test_claim_user_with_project_id_adds_corresponding_claimed_tag_to_user(self): - assert OsfClaimedTags.Osf.value not in self.user.system_tags - url = self.user.get_claim_url(self.project_with_source_tag._primary_key) - res = self.app.post(url, data={ - 'username': self.user.username, - 'password': 'killerqueen', - 'password2': 'killerqueen' - }) - - assert res.status_code == 302 - self.user.reload() - assert OsfClaimedTags.Osf.value in self.user.system_tags - - def test_claim_user_with_preprint_id_adds_corresponding_claimed_tag_to_user(self): - assert provider_claimed_tag(self.preprint_with_source_tag.provider._id, 'preprint') not in self.user.system_tags - url = self.user.get_claim_url(self.preprint_with_source_tag._primary_key) - res = self.app.post(url, data={ - 'username': self.user.username, - 'password': 'killerqueen', - 'password2': 'killerqueen' - }) - - assert res.status_code == 302 - self.user.reload() - assert provider_claimed_tag(self.preprint_with_source_tag.provider._id, 'preprint') in self.user.system_tags + send_claim_email(email=fake_email(), unclaimed_user=unreg_user, node=project) diff --git a/tests/test_addons.py b/tests/test_addons.py index f6fda06a024..fe97b2eabd8 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -1,7 +1,6 @@ import datetime import time import functools -import logging from importlib import import_module from unittest.mock import Mock @@ -16,14 +15,14 @@ from framework.auth.core import Auth from framework.exceptions import HTTPError from framework.sessions import get_session -from tests.base import OsfTestCase, get_default_metaschema +from tests.base import OsfTestCase from api_tests.utils import create_test_file from osf_tests.factories import ( AuthUserFactory, ProjectFactory, RegistrationFactory, - DraftRegistrationFactory, ) +from tests.utils import capture_notifications from website import settings from addons.base import views from addons.github.exceptions import ApiError @@ -44,8 +43,6 @@ from api.caching.utils import storage_usage_cache from dateutil.parser import parse as parse_date from framework import sentry -from api.base.settings.defaults import API_BASE -from tests.json_api_test_app import JSONAPITestApp from website.settings import EXTERNAL_EMBER_APPS from waffle.testutils import override_flag from django.conf import settings as django_conf_settings @@ -354,18 +351,19 @@ def build_payload_with_dest(self, destination, **kwargs): 'signature': signature, } - @mock.patch('website.notifications.events.files.FileAdded.perform') - def test_add_log(self, mock_perform): - path = 'pizza' + def test_add_log(self): url = self.node.api_url_for('create_waterbutler_log') - payload = self.build_payload(metadata={'nid': self.node._id, 'path': path}) + payload = self.build_payload(metadata={ + 'nid': self.node._id, + 'materialized': '/pizza', + 'path': 'pizza' + } + ) nlogs = self.node.logs.count() - self.app.put(url, json=payload) + with capture_notifications(): + self.app.put(url, json=payload) self.node.reload() assert self.node.logs.count() == nlogs + 1 - # # Mocking form_message and perform so that the payload need not be exact. - # assert mock_form_message.called, "form_message not called" - assert mock_perform.called, 'perform not called' def test_add_log_missing_args(self): path = 'pizza' @@ -445,11 +443,12 @@ def test_action_file_rename(self): 'kind': 'file', }, ) - self.app.put( - url, - json=payload, - headers={'Content-Type': 'application/json'} - ) + with capture_notifications(): + self.app.put( + url, + json=payload, + headers={'Content-Type': 'application/json'} + ) self.node.reload() assert self.node.logs.latest().action == 'github_addon_file_renamed' @@ -478,11 +477,12 @@ def test_action_file_rename_storage(self): 'kind': 'file', }, ) - self.app.put( - url, - json=payload, - headers={'Content-Type': 'application/json'} - ) + with capture_notifications(): + self.app.put( + url, + json=payload, + headers={'Content-Type': 'application/json'} + ) self.node.reload() assert self.node.storage_usage == current_usage @@ -521,7 +521,8 @@ def test_add_log_updates_cache_rename_via_move(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 250 @@ -554,7 +555,8 @@ def test_add_file_osfstorage_log(self): url = self.node.api_url_for('create_waterbutler_log') payload = self.build_payload(metadata={'materialized': path, 'kind': 'file', 'path': path}) nlogs = self.node.logs.count() - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) self.node.reload() assert self.node.logs.count() == nlogs + 1 assert ('urls' in self.node.logs.filter(action='osf_storage_file_added')[0].params) @@ -571,7 +573,8 @@ def test_add_log_updates_cache_create(self): 'size': 100, 'nid': self.node._id, }) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) self.node.reload() assert self.node.storage_usage == 100 @@ -587,7 +590,8 @@ def test_add_log_updates_cache_update(self): 'size': 120, 'nid': self.node._id, }, action='update') - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) self.node.reload() assert self.node.storage_usage == 120 @@ -600,7 +604,8 @@ def test_add_log_updates_cache_update(self): 'nid': self.node._id, }, action='update') - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) self.node.reload() assert self.node.storage_usage == 260 @@ -638,7 +643,8 @@ def test_add_log_updates_cache_move(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 0 @@ -690,7 +696,8 @@ def test_add_log_updates_cache_move_multiversion(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 0 @@ -733,7 +740,8 @@ def test_add_log_updates_cache_move_outside_osf(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 0 @@ -764,7 +772,8 @@ def test_add_log_updates_cache_move_into_osf(self): 'size': 220 }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 220 @@ -804,7 +813,8 @@ def test_add_log_updates_cache_copy(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 250 @@ -857,7 +867,8 @@ def test_add_log_updates_cache_copy_multiversion(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 525 @@ -900,7 +911,8 @@ def test_add_log_updates_cache_copy_same_node(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 500 @@ -951,7 +963,8 @@ def test_add_log_updates_cache_copy_same_node_multiversion(self): 'name': 'new.txt', }, ) - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 1050 @@ -981,7 +994,8 @@ def test_add_log_updates_cache_delete(self): 'path': '/lollipop', 'nid': self.node._id, }, action='delete') - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 0 @@ -1021,7 +1035,8 @@ def test_add_log_updates_cache_delete_multiversion(self): 'path': '/lollipop', 'nid': self.node._id, }, action='delete') - self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) + with capture_notifications(): + self.app.put(url, json=payload, headers={'Content-Type': 'application/json'}) key = cache_settings.STORAGE_USAGE_KEY.format(target_id=self.node._id) assert storage_usage_cache.get(key) == 0 @@ -1032,7 +1047,8 @@ def test_add_folder_osfstorage_log(self): url = self.node.api_url_for('create_waterbutler_log') payload = self.build_payload(metadata={'materialized': path, 'kind': 'folder', 'path': path}) nlogs = self.node.logs.count() - self.app.put(url, json=payload) + with capture_notifications(): + self.app.put(url, json=payload) self.node.reload() assert self.node.logs.count() == nlogs + 1 assert ('urls' not in self.node.logs.filter(action='osf_storage_file_added')[0].params) @@ -1546,13 +1562,14 @@ def test_resolve_folder_raise(self): def test_delete_action_creates_trashed_file_node(self): file_node = self.get_test_file() payload = { + 'action': 'file_removed', 'provider': file_node.provider, 'metadata': { 'path': '/test/Test', 'materialized': '/test/Test' } } - views.addon_delete_file_node(self=None, target=self.project, user=self.user, event_type='file_removed', payload=payload) + views.addon_delete_file_node(self=None, target=self.project, user=self.user, payload=payload) assert not GithubFileNode.load(file_node._id) assert TrashedFileNode.load(file_node._id) @@ -1566,13 +1583,14 @@ def test_delete_action_for_folder_deletes_subfolders_and_creates_trashed_file_no ) subfolder.save() payload = { + 'action': 'file_removed', 'provider': file_node.provider, 'metadata': { 'path': '/test/', 'materialized': '/test/' } } - views.addon_delete_file_node(self=None, target=self.project, user=self.user, event_type='file_removed', payload=payload) + views.addon_delete_file_node(self=None, target=self.project, user=self.user, payload=payload) assert not GithubFileNode.load(subfolder._id) assert TrashedFileNode.load(file_node._id) diff --git a/tests/test_auth.py b/tests/test_auth.py index 6088c608e67..78b772d1c7a 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -6,7 +6,7 @@ import logging from unittest import mock -from urllib.parse import urlparse, quote +from urllib.parse import urlparse from rest_framework import status as http_status from flask import Flask from werkzeug.wrappers import Response @@ -24,9 +24,9 @@ from framework.auth import Auth from framework.auth.decorators import must_be_logged_in from framework.sessions import get_session -from osf.models import OSFUser +from osf.models import OSFUser, NotificationType from osf.utils import permissions -from website import mails +from tests.utils import capture_notifications from website import settings from website.project.decorators import ( must_have_permission, @@ -36,21 +36,14 @@ must_have_addon, must_be_addon_authorizer, ) from website.util import api_url_for -from conftest import start_mock_send_grid from tests.test_cas_authentication import generate_external_user_with_resp logger = logging.getLogger(__name__) -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestAuthUtils(OsfTestCase): - def setUp(self): - super().setUp() - self.mock_send_grid = start_mock_send_grid(self) - def test_citation_with_only_fullname(self): user = UserFactory() user.fullname = 'Martin Luther King, Jr.' @@ -97,9 +90,6 @@ def test_confirm_email(self): user.reload() - self.mock_send_grid.assert_not_called() - - self.app.set_cookie(settings.COOKIE_NAME, user.get_or_create_cookie().decode()) res = self.app.get(f'/confirm/{user._id}/{token}') @@ -107,7 +97,6 @@ def test_confirm_email(self): assert res.status_code == 302 assert '/' == urlparse(res.location).path - assert len(self.mock_send_grid.call_args_list) == 0 assert len(get_session()['status']) == 1 def test_get_user_by_id(self): @@ -120,7 +109,8 @@ def test_get_user_by_email(self): def test_get_user_with_wrong_password_returns_false(self): user = UserFactory.build() - user.set_password('killerqueen') + with capture_notifications(): + user.set_password('killerqueen') assert not auth.get_user(email=user.username, password='wrong') def test_get_user_by_external_info(self): @@ -171,13 +161,11 @@ def test_successful_external_first_login_without_attributes(self, mock_service_v def test_password_change_sends_email(self): user = UserFactory() - user.set_password('killerqueen') - user.save() - assert len(self.mock_send_grid.call_args_list) == 1 - empty, kwargs = self.mock_send_grid.call_args - - assert empty == () - assert kwargs['to_addr'] == user.username + with capture_notifications() as notifications: + user.set_password('killerqueen') + user.save() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_PASSWORD_RESET @mock.patch('framework.auth.utils.requests.post') def test_validate_recaptcha_success(self, req_post): @@ -219,11 +207,15 @@ def test_sign_up_twice_sends_two_confirmation_emails_only(self): 'password': 'brutusisajerk' } - self.app.post(url, json=sign_up_data) - assert len(self.mock_send_grid.call_args_list) == 1 + with capture_notifications() as notifications: + self.app.post(url, json=sign_up_data) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_INITIAL_CONFIRM_EMAIL - self.app.post(url, json=sign_up_data) - assert len(self.mock_send_grid.call_args_list) == 2 + with capture_notifications() as notifications: + self.app.post(url, json=sign_up_data) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_INITIAL_CONFIRM_EMAIL class TestAuthObject(OsfTestCase): diff --git a/tests/test_auth_views.py b/tests/test_auth_views.py index 31445da2c8d..4f43d681ae5 100644 --- a/tests/test_auth_views.py +++ b/tests/test_auth_views.py @@ -12,7 +12,7 @@ from django.utils import timezone from flask import request from rest_framework import status as http_status -from tests.utils import run_celery_tasks +from tests.utils import run_celery_tasks, capture_notifications from framework import auth from framework.auth import Auth, cas @@ -25,7 +25,7 @@ ) from framework.auth.exceptions import InvalidTokenError from framework.auth.views import login_and_register_handler -from osf.models import OSFUser, NotableDomain +from osf.models import OSFUser, NotableDomain, NotificationType from osf_tests.factories import ( fake_email, AuthUserFactory, @@ -38,14 +38,11 @@ fake, OsfTestCase, ) -from website import mails, settings +from website import settings from website.util import api_url_for, web_url_for -from conftest import start_mock_send_grid pytestmark = pytest.mark.django_db -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestAuthViews(OsfTestCase): def setUp(self): @@ -53,20 +50,19 @@ def setUp(self): self.user = AuthUserFactory() self.auth = self.user.auth - self.mock_send_grid = start_mock_send_grid(self) - def test_register_ok(self): url = api_url_for('register_user') name, email, password = fake.name(), fake_email(), 'underpressure' - self.app.post( - url, - json={ - 'fullName': name, - 'email1': email, - 'email2': email, - 'password': password, - } - ) + with capture_notifications(): + self.app.post( + url, + json={ + 'fullName': name, + 'email1': email, + 'email2': email, + 'password': password, + } + ) user = OSFUser.objects.get(username=email) assert user.fullname == name assert user.accepted_terms_of_service is None @@ -74,47 +70,50 @@ def test_register_ok(self): def test_register_email_case_insensitive(self): url = api_url_for('register_user') name, email, password = fake.name(), fake_email(), 'underpressure' - self.app.post( - url, - json={ - 'fullName': name, - 'email1': email, - 'email2': str(email).upper(), - 'password': password, - } - ) + with capture_notifications(): + self.app.post( + url, + json={ + 'fullName': name, + 'email1': email, + 'email2': str(email).upper(), + 'password': password, + } + ) user = OSFUser.objects.get(username=email) assert user.fullname == name def test_register_email_with_accepted_tos(self): url = api_url_for('register_user') name, email, password = fake.name(), fake_email(), 'underpressure' - self.app.post( - url, - json={ - 'fullName': name, - 'email1': email, - 'email2': email, - 'password': password, - 'acceptedTermsOfService': True - } - ) + with capture_notifications(): + self.app.post( + url, + json={ + 'fullName': name, + 'email1': email, + 'email2': email, + 'password': password, + 'acceptedTermsOfService': True + } + ) user = OSFUser.objects.get(username=email) assert user.accepted_terms_of_service def test_register_email_without_accepted_tos(self): url = api_url_for('register_user') name, email, password = fake.name(), fake_email(), 'underpressure' - self.app.post( - url, - json={ - 'fullName': name, - 'email1': email, - 'email2': email, - 'password': password, - 'acceptedTermsOfService': False - } - ) + with capture_notifications(): + self.app.post( + url, + json={ + 'fullName': name, + 'email1': email, + 'email2': email, + 'password': password, + 'acceptedTermsOfService': False + } + ) user = OSFUser.objects.get(username=email) assert user.accepted_terms_of_service is None @@ -123,15 +122,16 @@ def test_register_scrubs_username(self, _): url = api_url_for('register_user') name = "Eunice O' \"Cornwallis\"" email, password = fake_email(), 'underpressure' - res = self.app.post( - url, - json={ - 'fullName': name, - 'email1': email, - 'email2': email, - 'password': password, - } - ) + with capture_notifications(): + res = self.app.post( + url, + json={ + 'fullName': name, + 'email1': email, + 'email2': email, + 'password': password, + } + ) expected_scrub_username = "Eunice O' \"Cornwallis\"cornify_add()" user = OSFUser.objects.get(username=email) @@ -200,16 +200,17 @@ def test_register_good_captcha(self, validate_recaptcha): name, email, password = fake.name(), fake_email(), 'underpressure' captcha = 'some valid captcha' with mock.patch.object(settings, 'RECAPTCHA_SITE_KEY', 'some_value'): - resp = self.app.post( - url, - json={ - 'fullName': name, - 'email1': email, - 'email2': str(email).upper(), - 'password': password, - 'g-recaptcha-response': captcha, - } - ) + with capture_notifications(): + resp = self.app.post( + url, + json={ + 'fullName': name, + 'email1': email, + 'email2': str(email).upper(), + 'password': password, + 'g-recaptcha-response': captcha, + } + ) validate_recaptcha.assert_called_with(captcha, remote_ip='127.0.0.1') assert resp.status_code == http_status.HTTP_200_OK user = OSFUser.objects.get(username=email) @@ -283,7 +284,8 @@ def test_register_after_being_invited_as_unreg_contributor(self, mock_update_sea 'password': password, } # Send registration request - self.app.post(url, json=payload) + with capture_notifications(): + self.app.post(url, json=payload) new_user.reload() @@ -320,8 +322,11 @@ def test_resend_confirmation(self): self.user.save() url = api_url_for('resend_confirmation') header = {'address': email, 'primary': False, 'confirmed': False} - self.app.put(url, json={'id': self.user._id, 'email': header}, auth=self.user.auth) - assert self.mock_send_grid.called + with capture_notifications() as notifications: + self.app.put(url, json={'id': self.user._id, 'email': header}, auth=self.user.auth) + + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_CONFIRM_EMAIL self.user.reload() assert token != self.user.get_confirmation_token(email) @@ -497,8 +502,10 @@ def test_resend_confirmation_does_not_send_before_throttle_expires(self): self.user.save() url = api_url_for('resend_confirmation') header = {'address': email, 'primary': False, 'confirmed': False} - self.app.put(url, json={'id': self.user._id, 'email': header}, auth=self.user.auth) - assert self.mock_send_grid.called + with capture_notifications() as notifications: + self.app.put(url, json={'id': self.user._id, 'email': header}, auth=self.user.auth) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_CONFIRM_EMAIL # 2nd call does not send email because throttle period has not expired res = self.app.put(url, json={'id': self.user._id, 'email': header}, auth=self.user.auth) assert res.status_code == 400 @@ -875,7 +882,8 @@ def test_can_reset_password_if_form_success(self, mock_service_validate): form = res.get_form('resetPasswordForm') form['password'] = 'newpassword' form['password2'] = 'newpassword' - res = form.submit(self.app) + with capture_notifications(): + res = form.submit(self.app) # check request URL is /resetpassword with username and new verification_key_v2 token request_url_path = res.request.path diff --git a/tests/test_claim_views.py b/tests/test_claim_views.py new file mode 100644 index 00000000000..f682e60faff --- /dev/null +++ b/tests/test_claim_views.py @@ -0,0 +1,500 @@ +import pytest +from flask import g + +from http.cookies import SimpleCookie +from unittest import mock + +from framework.auth import Auth, authenticate, cas +from framework.auth.utils import impute_names_model +from framework.exceptions import HTTPError +from framework.flask import redirect +from osf.models import ( + OSFUser, + Tag, NotificationType, +) +from osf_tests.factories import ( + fake_email, + AuthUserFactory, + PreprintFactory, + ProjectFactory, + UserFactory, + UnregUserFactory, +) +from tests.base import ( + fake, + OsfTestCase, +) +from tests.test_cas_authentication import generate_external_user_with_resp +from tests.utils import capture_notifications +from website import settings +from website.project.views.contributor import send_claim_registered_email +from website.util.metrics import ( + OsfSourceTags, + OsfClaimedTags, + provider_source_tag, + provider_claimed_tag +) + + +@pytest.mark.enable_implicit_clean +class TestClaimViews(OsfTestCase): + + def setUp(self): + super().setUp() + self.referrer = AuthUserFactory() + self.project = ProjectFactory(creator=self.referrer, is_public=True) + self.project_with_source_tag = ProjectFactory(creator=self.referrer, is_public=True) + self.preprint_with_source_tag = PreprintFactory(creator=self.referrer, is_public=True) + osf_source_tag, created = Tag.all_tags.get_or_create(name=OsfSourceTags.Osf.value, system=True) + preprint_source_tag, created = Tag.all_tags.get_or_create(name=provider_source_tag(self.preprint_with_source_tag.provider._id, 'preprint'), system=True) + self.project_with_source_tag.add_system_tag(osf_source_tag.name) + self.preprint_with_source_tag.add_system_tag(preprint_source_tag.name) + self.given_name = fake.name() + self.given_email = fake_email() + self.project_with_source_tag.add_unregistered_contributor( + fullname=self.given_name, + email=self.given_email, + auth=Auth(user=self.referrer), + notification_type=False + ) + self.preprint_with_source_tag.add_unregistered_contributor( + fullname=self.given_name, + email=self.given_email, + auth=Auth(user=self.referrer), + notification_type=False + ) + self.user = self.project.add_unregistered_contributor( + fullname=self.given_name, + email=self.given_email, + auth=Auth(user=self.referrer), + notification_type=False + ) + self.project.save() + + def test_claim_user_already_registered_redirects_to_claim_user_registered(self): + name = fake.name() + email = fake_email() + + # project contributor adds an unregistered contributor (without an email) on public project + unregistered_user = self.project.add_unregistered_contributor( + fullname=name, + email=None, + auth=Auth(user=self.referrer), + notification_type=False + ) + assert unregistered_user in self.project.contributors + + # unregistered user comes along and claims themselves on the public project, entering an email + invite_url = self.project.api_url_for( + 'claim_user_post', + uid='undefined' + ) + with capture_notifications() as notifications: + self.app.post( + invite_url, + json={ + 'pk': unregistered_user._primary_key, + 'value': email + } + ) + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_PENDING_VERIFICATION + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_FORWARD_INVITE + + # set unregistered record email since we are mocking send_claim_email() + unclaimed_record = unregistered_user.get_unclaimed_record(self.project._primary_key) + unclaimed_record.update({'email': email}) + unregistered_user.save() + + # unregistered user then goes and makes an account with same email, before claiming themselves as contributor + UserFactory(username=email, fullname=name) + + # claim link for the now registered email is accessed while not logged in + token = unregistered_user.get_unclaimed_record(self.project._primary_key)['token'] + claim_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/?token={token}' + res = self.app.get(claim_url) + + # should redirect to 'claim_user_registered' view + claim_registered_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/verify/{token}/' + assert res.status_code == 302 + assert claim_registered_url in res.headers.get('Location') + + def test_claim_user_already_registered_secondary_email_redirects_to_claim_user_registered(self): + name = fake.name() + email = fake_email() + secondary_email = fake_email() + + # project contributor adds an unregistered contributor (without an email) on public project + unregistered_user = self.project.add_unregistered_contributor( + fullname=name, + email=None, + auth=Auth(user=self.referrer), + notification_type=False + ) + assert unregistered_user in self.project.contributors + + # unregistered user comes along and claims themselves on the public project, entering an email + invite_url = self.project.api_url_for( + 'claim_user_post', + uid='undefined' + ) + with capture_notifications() as notifications: + self.app.post( + invite_url, + json={ + 'pk': unregistered_user._primary_key, + 'value': secondary_email + } + ) + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_PENDING_VERIFICATION + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_FORWARD_INVITE + + # set unregistered record email since we are mocking send_claim_email() + unclaimed_record = unregistered_user.get_unclaimed_record(self.project._primary_key) + unclaimed_record.update({'email': secondary_email}) + unregistered_user.save() + + # unregistered user then goes and makes an account with same email, before claiming themselves as contributor + registered_user = UserFactory(username=email, fullname=name) + registered_user.emails.create(address=secondary_email) + registered_user.save() + + # claim link for the now registered email is accessed while not logged in + token = unregistered_user.get_unclaimed_record(self.project._primary_key)['token'] + claim_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/?token={token}' + res = self.app.get(claim_url) + + # should redirect to 'claim_user_registered' view + claim_registered_url = f'/user/{unregistered_user._id}/{self.project._id}/claim/verify/{token}/' + assert res.status_code == 302 + assert claim_registered_url in res.headers.get('Location') + + def test_claim_user_invited_with_no_email_posts_to_claim_form(self): + given_name = fake.name() + invited_user = self.project.add_unregistered_contributor( + fullname=given_name, + email=None, + auth=Auth(user=self.referrer), + notification_type=False + ) + self.project.save() + + url = invited_user.get_claim_url(self.project._primary_key) + res = self.app.post(url, data={ + 'password': 'bohemianrhap', + 'password2': 'bohemianrhap' + }) + assert res.status_code == 400 + + def test_claim_user_post_with_registered_user_id(self): + # registered user who is attempting to claim the unclaimed contributor + reg_user = UserFactory() + with capture_notifications() as notifications: + res = self.app.post( + f'/api/v1/user/{self.user._primary_key}/{self.project._primary_key}/claim/email/', + json={ + # pk of unreg user record + 'pk': self.user._primary_key, + 'claimerId': reg_user._primary_key + } + ) + + # mail was sent + assert len(notifications['emits']) == 2 + # ... to the correct address + assert notifications['emits'][0]['kwargs']['user'] == self.referrer + assert notifications['emits'][1]['kwargs']['user'] == reg_user + + # view returns the correct JSON + assert res.json == { + 'status': 'success', + 'email': reg_user.username, + 'fullname': self.given_name, + } + + def test_send_claim_registered_email(self): + reg_user = UserFactory() + with capture_notifications() as notifications: + send_claim_registered_email( + claimer=reg_user, + unclaimed_user=self.user, + node=self.project + ) + assert len(notifications['emits']) == 2 + # ... to the correct address + assert notifications['emits'][0]['kwargs']['user'] == self.referrer + assert notifications['emits'][1]['kwargs']['user'] == reg_user + + def test_send_claim_registered_email_before_throttle_expires(self): + reg_user = UserFactory() + with mock.patch('osf.email.send_email_with_send_grid', return_value=None): + with capture_notifications(passthrough=True) as notifications: + send_claim_registered_email( + claimer=reg_user, + unclaimed_user=self.user, + node=self.project, + ) + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_FORWARD_INVITE_REGISTERED + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_PENDING_VERIFICATION_REGISTERED + # second call raises error because it was called before throttle period + with pytest.raises(HTTPError): + send_claim_registered_email( + claimer=reg_user, + unclaimed_user=self.user, + node=self.project, + ) + + @mock.patch('website.project.views.contributor.send_claim_registered_email') + def test_claim_user_post_with_email_already_registered_sends_correct_email( + self, send_claim_registered_email): + reg_user = UserFactory() + payload = { + 'value': reg_user.username, + 'pk': self.user._primary_key + } + url = self.project.api_url_for('claim_user_post', uid=self.user._id) + self.app.post(url, json=payload) + assert send_claim_registered_email.called + + def test_user_with_removed_unclaimed_url_claiming(self): + """ Tests that when an unclaimed user is removed from a project, the + unregistered user object does not retain the token. + """ + self.project.remove_contributor(self.user, Auth(user=self.referrer)) + + assert self.project._primary_key not in self.user.unclaimed_records.keys() + + def test_user_with_claim_url_cannot_claim_twice(self): + """ Tests that when an unclaimed user is replaced on a project with a + claimed user, the unregistered user object does not retain the token. + """ + reg_user = AuthUserFactory() + + self.project.replace_contributor(self.user, reg_user) + + assert self.project._primary_key not in self.user.unclaimed_records.keys() + + def test_claim_user_form_redirects_to_password_confirm_page_if_user_is_logged_in(self): + reg_user = AuthUserFactory() + url = self.user.get_claim_url(self.project._primary_key) + res = self.app.get(url, auth=reg_user.auth) + assert res.status_code == 302 + res = self.app.get(url, auth=reg_user.auth, follow_redirects=True) + token = self.user.get_unclaimed_record(self.project._primary_key)['token'] + expected = self.project.web_url_for( + 'claim_user_registered', + uid=self.user._id, + token=token, + ) + assert res.request.path == expected + + @mock.patch('framework.auth.cas.make_response_from_ticket') + def test_claim_user_when_user_is_registered_with_orcid(self, mock_response_from_ticket): + # TODO: check in qa url encoding + token = self.user.get_unclaimed_record(self.project._primary_key)['token'] + url = f'/user/{self.user._id}/{self.project._id}/claim/verify/{token}/' + # logged out user gets redirected to cas login + res1 = self.app.get(url) + assert res1.status_code == 302 + res = self.app.resolve_redirect(self.app.get(url)) + service_url = f'http://localhost{url}' + expected = cas.get_logout_url(service_url=cas.get_login_url(service_url=service_url)) + assert res1.location == expected + + # user logged in with orcid automatically becomes a contributor + orcid_user, validated_credentials, cas_resp = generate_external_user_with_resp(url) + mock_response_from_ticket.return_value = authenticate( + orcid_user, + redirect(url) + ) + orcid_user.set_unusable_password() + orcid_user.save() + + # The request to OSF with CAS service ticket must not have cookie and/or auth. + service_ticket = fake.md5() + url_with_service_ticket = f'{url}?ticket={service_ticket}' + res = self.app.get(url_with_service_ticket) + # The response of this request is expected to be a 302 with `Location`. + # And the redirect URL must equal to the originial service URL + assert res.status_code == 302 + redirect_url = res.headers['Location'] + assert redirect_url == url + # The response of this request is expected have the `Set-Cookie` header with OSF cookie. + # And the cookie must belong to the ORCiD user. + raw_set_cookie = res.headers['Set-Cookie'] + assert raw_set_cookie + simple_cookie = SimpleCookie() + simple_cookie.load(raw_set_cookie) + cookie_dict = {key: value.value for key, value in simple_cookie.items()} + osf_cookie = cookie_dict.get(settings.COOKIE_NAME, None) + assert osf_cookie is not None + user = OSFUser.from_cookie(osf_cookie) + assert user._id == orcid_user._id + # The ORCiD user must be different from the unregistered user created when the contributor was added + assert user._id != self.user._id + + # Must clear the Flask g context manual and set the OSF cookie to context + g.current_session = None + self.app.set_cookie(settings.COOKIE_NAME, osf_cookie) + res = self.app.resolve_redirect(res) + assert res.status_code == 302 + assert self.project.is_contributor(orcid_user) + assert self.project.url in res.headers.get('Location') + + def test_get_valid_form(self): + url = self.user.get_claim_url(self.project._primary_key) + res = self.app.get(url, follow_redirects=True) + assert res.status_code == 200 + + def test_invalid_claim_form_raise_400(self): + uid = self.user._primary_key + pid = self.project._primary_key + url = f'/user/{uid}/{pid}/claim/?token=badtoken' + res = self.app.get(url, follow_redirects=True) + assert res.status_code == 400 + + @mock.patch('osf.models.OSFUser.update_search_nodes') + def test_posting_to_claim_form_with_valid_data(self, mock_update_search_nodes): + url = self.user.get_claim_url(self.project._primary_key) + res = self.app.post(url, data={ + 'username': self.user.username, + 'password': 'killerqueen', + 'password2': 'killerqueen' + }) + + assert res.status_code == 302 + location = res.headers.get('Location') + assert 'login?service=' in location + assert 'username' in location + assert 'verification_key' in location + assert self.project._primary_key in location + + self.user.reload() + assert self.user.is_registered + assert self.user.is_active + assert self.project._primary_key not in self.user.unclaimed_records + + @mock.patch('osf.models.OSFUser.update_search_nodes') + def test_posting_to_claim_form_removes_all_unclaimed_data(self, mock_update_search_nodes): + # user has multiple unclaimed records + p2 = ProjectFactory(creator=self.referrer) + self.user.add_unclaimed_record(p2, referrer=self.referrer, + given_name=fake.name()) + self.user.save() + assert len(self.user.unclaimed_records.keys()) > 1 # sanity check + url = self.user.get_claim_url(self.project._primary_key) + res = self.app.post(url, data={ + 'username': self.given_email, + 'password': 'bohemianrhap', + 'password2': 'bohemianrhap' + }) + self.user.reload() + assert self.user.unclaimed_records == {} + + @mock.patch('osf.models.OSFUser.update_search_nodes') + def test_posting_to_claim_form_sets_fullname_to_given_name(self, mock_update_search_nodes): + # User is created with a full name + original_name = fake.name() + unreg = UnregUserFactory(fullname=original_name) + # User invited with a different name + different_name = fake.name() + new_user = self.project.add_unregistered_contributor( + email=unreg.username, + fullname=different_name, + auth=Auth(self.project.creator), + ) + self.project.save() + # Goes to claim url + claim_url = new_user.get_claim_url(self.project._id) + self.app.post(claim_url, data={ + 'username': unreg.username, + 'password': 'killerqueen', + 'password2': 'killerqueen' + }) + unreg.reload() + # Full name was set correctly + assert unreg.fullname == different_name + # CSL names were set correctly + parsed_name = impute_names_model(different_name) + assert unreg.given_name == parsed_name['given_name'] + assert unreg.family_name == parsed_name['family_name'] + + def test_claim_user_post_returns_fullname(self): + with capture_notifications() as notifications: + res = self.app.post( + f'/api/v1/user/{self.user._primary_key}/{self.project._primary_key}/claim/email/', + auth=self.referrer.auth, + json={ + 'value': self.given_email, + 'pk': self.user._primary_key + }, + ) + assert res.json['fullname'] == self.given_name + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_INVITE_DEFAULT + + def test_claim_user_post_if_email_is_different_from_given_email(self): + email = fake_email() # email that is different from the one the referrer gave + with capture_notifications() as notifications: + self.app.post( + f'/api/v1/user/{self.user._primary_key}/{self.project._primary_key}/claim/email/', + json={ + 'value': email, + 'pk': self.user._primary_key + } + ) + assert len(notifications['emits']) == 2 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_PENDING_VERIFICATION + assert notifications['emits'][0]['kwargs']['user'].username == self.given_email + assert notifications['emits'][1]['type'] == NotificationType.Type.USER_FORWARD_INVITE + assert notifications['emits'][1]['kwargs']['destination_address'] == email + + def test_claim_url_with_bad_token_returns_400(self): + url = self.project.web_url_for( + 'claim_user_registered', + uid=self.user._id, + token='badtoken', + ) + res = self.app.get(url, auth=self.referrer.auth) + assert res.status_code == 400 + + def test_cannot_claim_user_with_user_who_is_already_contributor(self): + # user who is already a contirbutor to the project + contrib = AuthUserFactory() + self.project.add_contributor(contrib, auth=Auth(self.project.creator)) + self.project.save() + # Claiming user goes to claim url, but contrib is already logged in + url = self.user.get_claim_url(self.project._primary_key) + res = self.app.get( + url, + auth=contrib.auth, follow_redirects=True) + # Response is a 400 + assert res.status_code == 400 + + def test_claim_user_with_project_id_adds_corresponding_claimed_tag_to_user(self): + assert OsfClaimedTags.Osf.value not in self.user.system_tags + url = self.user.get_claim_url(self.project_with_source_tag._primary_key) + res = self.app.post(url, data={ + 'username': self.user.username, + 'password': 'killerqueen', + 'password2': 'killerqueen' + }) + + assert res.status_code == 302 + self.user.reload() + assert OsfClaimedTags.Osf.value in self.user.system_tags + + def test_claim_user_with_preprint_id_adds_corresponding_claimed_tag_to_user(self): + assert provider_claimed_tag(self.preprint_with_source_tag.provider._id, 'preprint') not in self.user.system_tags + url = self.user.get_claim_url(self.preprint_with_source_tag._primary_key) + res = self.app.post(url, data={ + 'username': self.user.username, + 'password': 'killerqueen', + 'password2': 'killerqueen' + }) + + assert res.status_code == 302 + self.user.reload() + assert provider_claimed_tag(self.preprint_with_source_tag.provider._id, 'preprint') in self.user.system_tags diff --git a/tests/test_contributors_views.py b/tests/test_contributors_views.py index cae61b40cd2..525d503a9e9 100644 --- a/tests/test_contributors_views.py +++ b/tests/test_contributors_views.py @@ -4,6 +4,7 @@ from tests.base import OsfTestCase from framework.auth.decorators import Auth +from tests.utils import capture_notifications from website.profile import utils @@ -45,7 +46,8 @@ def test_serialize_access_requests(self): request_type=workflows.RequestTypes.ACCESS.value, machine_state=workflows.DefaultStates.INITIAL.value ) - node_request.run_submit(new_user) + with capture_notifications(): + node_request.run_submit(new_user) res = utils.serialize_access_requests(self.project) assert len(res) == 1 diff --git a/tests/test_events.py b/tests/test_events.py index 866bf6ec337..fa79515e021 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,18 +1,23 @@ from collections import OrderedDict -from unittest import mock -from pytest import raises -from website.notifications.events.base import Event, register, event_registry -from website.notifications.events.files import ( - FileAdded, FileRemoved, FolderCreated, FileUpdated, - AddonFileCopied, AddonFileMoved, AddonFileRenamed, +from django.contrib.contenttypes.models import ContentType + +from osf.models import NotificationType +from tests.utils import capture_notifications +from notifications.file_event_notifications import ( + event_registry, + FileAdded, + FileRemoved, + FolderCreated, + FileUpdated, + AddonFileCopied, + AddonFileMoved, + AddonFileRenamed, ) -from website.notifications.events import utils -from addons.base import signals from framework.auth import Auth from osf_tests import factories from osf.utils.permissions import WRITE -from tests.base import OsfTestCase, NotificationTestCase +from tests.base import OsfTestCase email_transactional = 'email_transactional' email_digest = 'email_digest' @@ -54,9 +59,6 @@ def setUp(self): ] } - def test_list_of_files(self): - assert ['e', 'f', 'c', 'd'] == utils.list_of_files(self.tree) - class TestEventExists(OsfTestCase): # Add all possible called events here to ensure that the Event class can @@ -108,21 +110,6 @@ def test_get_file_renamed(self): assert isinstance(event, AddonFileRenamed) -class TestSignalEvent(OsfTestCase): - def setUp(self): - super().setUp() - self.user = factories.UserFactory() - self.auth = Auth(user=self.user) - self.node = factories.ProjectFactory(creator=self.user) - - @mock.patch('website.notifications.events.files.FileAdded.perform') - def test_event_signal(self, mock_perform): - signals.file_updated.send( - user=self.user, target=self.node, event_type='file_added', payload=file_payload - ) - assert mock_perform.called - - class TestFileUpdated(OsfTestCase): def setUp(self): super().setUp() @@ -132,9 +119,9 @@ def setUp(self): self.project = factories.ProjectFactory(creator=self.user_1) # subscription self.sub = factories.NotificationSubscriptionFactory( - _id=self.project._id + 'file_updated', - owner=self.project, - event_name='file_updated', + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_UPDATED) ) self.sub.save() self.event = event_registry['file_updated'](self.user_2, self.project, 'file_updated', payload=file_payload) @@ -144,23 +131,23 @@ def test_info_formed_correct(self): assert f'updated file "{materialized.lstrip("/")}".' == self.event.html_message assert f'updated file "{materialized.lstrip("/")}".' == self.event.text_message - @mock.patch('website.notifications.emails.notify') - def test_file_updated(self, mock_notify): - self.event.perform() - # notify('exd', 'file_updated', 'user', self.project, timezone.now()) - assert mock_notify.called + def test_file_updated(self): + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.FILE_UPDATED -class TestFileAdded(NotificationTestCase): +class TestFileAdded(OsfTestCase): def setUp(self): super().setUp() self.user = factories.UserFactory() self.consolidate_auth = Auth(user=self.user) self.project = factories.ProjectFactory() self.project_subscription = factories.NotificationSubscriptionFactory( - _id=self.project._id + '_file_updated', - owner=self.project, - event_name='file_updated' + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_UPDATED) ) self.project_subscription.save() self.user2 = factories.UserFactory() @@ -171,24 +158,26 @@ def test_info_formed_correct(self): assert f'added file "{materialized.lstrip("/")}".' == self.event.html_message assert f'added file "{materialized.lstrip("/")}".' == self.event.text_message - @mock.patch('website.notifications.emails.notify') - def test_file_added(self, mock_notify): - self.event.perform() - # notify('exd', 'file_updated', 'user', self.project, timezone.now()) - assert mock_notify.called + def test_file_added(self): + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.FILE_ADDED -class TestFileRemoved(NotificationTestCase): +class TestFileRemoved(OsfTestCase): def setUp(self): super().setUp() self.user = factories.UserFactory() self.consolidate_auth = Auth(user=self.user) self.project = factories.ProjectFactory() self.project_subscription = factories.NotificationSubscriptionFactory( - _id=self.project._id + '_file_updated', - owner=self.project, - event_name='file_updated' + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_REMOVED) ) + self.project_subscription.object_id = self.project.id + self.project_subscription.content_type = ContentType.objects.get_for_model(self.project) self.project_subscription.save() self.user2 = factories.UserFactory() self.event = event_registry['file_removed']( @@ -196,33 +185,32 @@ def setUp(self): ) def test_info_formed_correct_file(self): - assert 'file_updated' == self.event.event_type + assert NotificationType.Type.FILE_UPDATED == self.event.event_type assert f'removed file "{materialized.lstrip("/")}".' == self.event.html_message assert f'removed file "{materialized.lstrip("/")}".' == self.event.text_message def test_info_formed_correct_folder(self): - assert 'file_updated' == self.event.event_type + assert NotificationType.Type.FILE_UPDATED == self.event.event_type self.event.payload['metadata']['materialized'] += '/' assert f'removed folder "{materialized.lstrip("/")}/".' == self.event.html_message assert f'removed folder "{materialized.lstrip("/")}/".' == self.event.text_message - @mock.patch('website.notifications.emails.notify') - def test_file_removed(self, mock_notify): - self.event.perform() - # notify('exd', 'file_updated', 'user', self.project, timezone.now()) - assert mock_notify.called + def test_file_removed(self): + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.FILE_REMOVED -class TestFolderCreated(NotificationTestCase): +class TestFolderCreated(OsfTestCase): def setUp(self): super().setUp() self.user = factories.UserFactory() self.consolidate_auth = Auth(user=self.user) self.project = factories.ProjectFactory() self.project_subscription = factories.NotificationSubscriptionFactory( - _id=self.project._id + '_file_updated', - owner=self.project, - event_name='file_updated' + user=self.user, + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_UPDATED), ) self.project_subscription.save() self.user2 = factories.UserFactory() @@ -231,14 +219,15 @@ def setUp(self): ) def test_info_formed_correct(self): - assert 'file_updated' == self.event.event_type + assert NotificationType.Type.FILE_UPDATED == self.event.event_type assert 'created folder "Three/".' == self.event.html_message assert 'created folder "Three/".' == self.event.text_message - @mock.patch('website.notifications.emails.notify') - def test_folder_added(self, mock_notify): - self.event.perform() - assert mock_notify.called + def test_folder_added(self): + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.FOLDER_CREATED class TestFolderFileRenamed(OsfTestCase): @@ -250,9 +239,10 @@ def setUp(self): self.project = factories.ProjectFactory(creator=self.user_1) # subscription self.sub = factories.NotificationSubscriptionFactory( - _id=self.project._id + 'file_updated', - owner=self.project, - event_name='file_updated', + user=self.user_2, + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.USER_FILE_UPDATED) ) self.sub.save() @@ -262,7 +252,6 @@ def setUp(self): self.user_1, self.project, 'addon_file_renamed', payload=file_renamed_payload ) - self.sub.email_digest.add(self.user_2) self.sub.save() def test_rename_file_html(self): @@ -286,7 +275,7 @@ def test_rename_folder_text(self): assert self.event.text_message == 'renamed folder "/One/Two/Three" to "/One/Two/Four".' -class TestFileMoved(NotificationTestCase): +class TestFileMoved(OsfTestCase): def setUp(self): super().setUp() self.user_1 = factories.AuthUserFactory() @@ -304,26 +293,23 @@ def setUp(self): # Subscriptions # for parent node self.sub = factories.NotificationSubscriptionFactory( - _id=self.project._id + '_file_updated', - owner=self.project, - event_name='file_updated' + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_UPDATED) ) self.sub.save() # for private node self.private_sub = factories.NotificationSubscriptionFactory( - _id=self.private_node._id + '_file_updated', - owner=self.private_node, - event_name='file_updated' + object_id=self.private_node.id, + content_type=ContentType.objects.get_for_model(self.private_node), + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_UPDATED) ) self.private_sub.save() # for file subscription self.file_sub = factories.NotificationSubscriptionFactory( - _id='{pid}_{wbid}_file_updated'.format( - pid=self.project._id, - wbid=self.event.waterbutler_id - ), - owner=self.project, - event_name='xyz42_file_updated' + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.NODE_FILES_UPDATED) ) self.file_sub.save() @@ -333,53 +319,59 @@ def test_info_formed_correct(self): # assert 'moved file "{}".' == self.event.html_message # assert 'created folder "Three/".' == self.event.text_message - @mock.patch('website.notifications.emails.store_emails') - def test_user_performing_action_no_email(self, mock_store): + def test_user_performing_action_no_email(self): # Move Event: Makes sure user who performed the action is not # included in the notifications - self.sub.email_digest.add(self.user_2) + self.sub.user = self.user_2 self.sub.save() - self.event.perform() - assert 0 == mock_store.call_count - - @mock.patch('website.notifications.emails.store_emails') - def test_perform_store_called_once(self, mock_store): - # Move Event: Tests that store_emails is called once from perform - self.sub.email_transactional.add(self.user_1) + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.ADDON_FILE_MOVED + assert notifications['emits'][0]['kwargs']['user'] == self.user_2 + + def test_perform_store_called_once(self): + self.sub.user = self.user_1 self.sub.save() - self.event.perform() - assert 1 == mock_store.call_count + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.ADDON_FILE_MOVED - @mock.patch('website.notifications.emails.store_emails') - def test_perform_store_one_of_each(self, mock_store): + def test_perform_store_one_of_each(self): # Move Event: Tests that store_emails is called 3 times, one in # each category - self.sub.email_transactional.add(self.user_1) + self.sub.user = self.user_1 + self.sub.save() self.project.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) self.project.save() self.private_node.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) self.private_node.save() - self.sub.email_digest.add(self.user_3) + self.sub.user = self.user_3 self.sub.save() self.project.add_contributor(self.user_4, permissions=WRITE, auth=self.auth) self.project.save() - self.file_sub.email_digest.add(self.user_4) + self.sub.user = self.user_4 + self.sub.save() self.file_sub.save() - self.event.perform() - assert 3 == mock_store.call_count + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.ADDON_FILE_MOVED - @mock.patch('website.notifications.emails.store_emails') - def test_remove_user_sent_once(self, mock_store): + def test_remove_user_sent_once(self): # Move Event: Tests removed user is removed once. Regression self.project.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) self.project.save() - self.file_sub.email_digest.add(self.user_3) + self.file_sub.user = self.user_3 self.file_sub.save() - self.event.perform() - assert 1 == mock_store.call_count + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.ADDON_FILE_MOVED -class TestFileCopied(NotificationTestCase): +class TestFileCopied(OsfTestCase): # Test the copying of files def setUp(self): super().setUp() @@ -399,26 +391,23 @@ def setUp(self): # Subscriptions # for parent node self.sub = factories.NotificationSubscriptionFactory( - _id=self.project._id + '_file_updated', - owner=self.project, - event_name='file_updated' + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_UPDATED) ) self.sub.save() # for private node self.private_sub = factories.NotificationSubscriptionFactory( - _id=self.private_node._id + '_file_updated', - owner=self.private_node, - event_name='file_updated' + object_id=self.private_node.id, + content_type=ContentType.objects.get_for_model(self.private_node), + notification_type=NotificationType.objects.get(name=NotificationType.Type.FILE_UPDATED) ) self.private_sub.save() # for file subscription self.file_sub = factories.NotificationSubscriptionFactory( - _id='{pid}_{wbid}_file_updated'.format( - pid=self.project._id, - wbid=self.event.waterbutler_id - ), - owner=self.project, - event_name='xyz42_file_updated' + object_id=self.project.id, + content_type=ContentType.objects.get_for_model(self.project), + notification_type=NotificationType.objects.get(name=NotificationType.Type.NODE_FILES_UPDATED) ) self.file_sub.save() @@ -432,138 +421,35 @@ def test_info_correct(self): ' in Consolidate to "Two/Paper13.txt" in OSF' ' Storage in Consolidate.') == self.event.text_message - @mock.patch('website.notifications.emails.store_emails') - def test_copied_one_of_each(self, mock_store): - # Copy Event: Tests that store_emails is called 2 times, two with + def test_copied_one_of_each(self): + # Copy Event: Tests that emit is called 2 times, two with # permissions, one without - self.sub.email_transactional.add(self.user_1) + self.sub.user = self.user_1 + self.sub.save() self.project.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) self.project.save() self.private_node.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) self.private_node.save() - self.sub.email_digest.add(self.user_3) + self.sub.user = self.user_3 self.sub.save() self.project.add_contributor(self.user_4, permissions=WRITE, auth=self.auth) self.project.save() - self.file_sub.email_digest.add(self.user_4) + self.file_sub.user = self.user_4 self.file_sub.save() - self.event.perform() - assert 2 == mock_store.call_count + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.ADDON_FILE_COPIED - @mock.patch('website.notifications.emails.store_emails') - def test_user_performing_action_no_email(self, mock_store): + def test_user_performing_action_no_email(self): # Move Event: Makes sure user who performed the action is not # included in the notifications - self.sub.email_digest.add(self.user_2) - self.sub.save() - self.event.perform() - assert 0 == mock_store.call_count - - -class TestCategorizeUsers(NotificationTestCase): - def setUp(self): - super().setUp() - 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 - ) - # Payload - file_moved_payload = file_move_payload(self.private_node, self.project) - self.event = event_registry['addon_file_moved']( - self.user_2, self.private_node, 'addon_file_moved', - payload=file_moved_payload - ) - # Subscriptions - # for parent node - self.sub = factories.NotificationSubscriptionFactory( - _id=self.project._id + '_file_updated', - owner=self.project, - event_name='file_updated' - ) - self.sub.save() - # for private node - self.private_sub = factories.NotificationSubscriptionFactory( - _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( - _id='{pid}_{wbid}_file_updated'.format( - pid=self.project._id, - wbid=self.event.waterbutler_id - ), - owner=self.project, - event_name='xyz42_file_updated' - ) - self.file_sub.save() - - def test_warn_user(self): - # Tests that a user with a sub in the origin node gets a warning that - # they are no longer tracking the file. - self.sub.email_transactional.add(self.user_1) - self.project.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) - self.project.save() - self.private_node.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) - self.private_node.save() - self.sub.email_digest.add(self.user_3) + self.sub.user = self.user_2 self.sub.save() - self.private_sub.none.add(self.user_3) - self.private_sub.save() - moved, warn, removed = utils.categorize_users( - self.event.user, self.event.event_type, self.event.source_node, - self.event.event_type, self.event.node - ) - assert {email_transactional: [], email_digest: [self.user_3._id], 'none': []} == warn - assert {email_transactional: [self.user_1._id], email_digest: [], 'none': []} == moved - - def test_moved_user(self): - # Doesn't warn a user with two different subs, but does send a - # moved email - self.project.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) - self.project.save() - self.private_node.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) - self.private_node.save() - self.sub.email_digest.add(self.user_3) - self.sub.save() - self.private_sub.email_transactional.add(self.user_3) - self.private_sub.save() - moved, warn, removed = utils.categorize_users( - self.event.user, self.event.event_type, self.event.source_node, - self.event.event_type, self.event.node - ) - assert {email_transactional: [], email_digest: [], 'none': []} == warn - assert {email_transactional: [self.user_3._id], email_digest: [], 'none': []} == moved - - def test_remove_user(self): - self.project.add_contributor(self.user_3, permissions=WRITE, auth=self.auth) - self.project.save() - self.file_sub.email_transactional.add(self.user_3) - self.file_sub.save() - moved, warn, removed = utils.categorize_users( - self.event.user, self.event.event_type, self.event.source_node, - self.event.event_type, self.event.node - ) - assert {email_transactional: [self.user_3._id], email_digest: [], 'none': []} == removed - - def test_node_permissions(self): - self.private_node.add_contributor(self.user_3, permissions=WRITE) - self.private_sub.email_digest.add(self.user_3, self.user_4) - remove = {email_transactional: [], email_digest: [], 'none': []} - warn = {email_transactional: [], email_digest: [self.user_3._id, self.user_4._id], 'none': []} - subbed, remove = utils.subscriptions_node_permissions( - self.private_node, - warn, - remove - ) - assert {email_transactional: [], email_digest: [self.user_3._id], 'none': []} == subbed - assert {email_transactional: [], email_digest: [self.user_4._id], 'none': []} == remove + with capture_notifications() as notifications: + self.event.perform() + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.ADDON_FILE_COPIED class TestSubscriptionManipulations(OsfTestCase): @@ -596,33 +482,6 @@ def setUp(self): self.dup_1_3 = {email_transactional: ['e1234', 'f1234'], 'none': ['h1234', 'g1234'], 'email_digest': ['a1234', 'c1234']} - def test_subscription_user_difference(self): - result = utils.subscriptions_users_difference(self.emails_1, self.emails_3) - assert self.diff_1_3 == result - - def test_subscription_user_union(self): - result = utils.subscriptions_users_union(self.emails_1, self.emails_2) - assert set(self.union_1_2['email_transactional']) == set(result['email_transactional']) - assert set(self.union_1_2['none']) == set(result['none']) - assert set(self.union_1_2['email_digest']) == set(result['email_digest']) - - def test_remove_duplicates(self): - result = utils.subscriptions_users_remove_duplicates( - self.emails_1, self.emails_4, remove_same=False - ) - assert set(self.dup_1_3['email_transactional']) == set(result['email_transactional']) - assert set(self.dup_1_3['none']) == set(result['none']) - assert set(self.dup_1_3['email_digest']) == set(result['email_digest']) - - def test_remove_duplicates_true(self): - result = utils.subscriptions_users_remove_duplicates( - self.emails_1, self.emails_1, remove_same=True - ) - - assert set(result['none']) == {'h1234', 'g1234', 'i1234'} - assert result['email_digest'] == [] - assert result['email_transactional'] == [] - wb_path = '5581cb50a24f710b0f4623f9' materialized = '/One/Paper13.txt' diff --git a/tests/test_forgot_password.py b/tests/test_forgot_password.py new file mode 100644 index 00000000000..681785100eb --- /dev/null +++ b/tests/test_forgot_password.py @@ -0,0 +1,230 @@ +from urllib.parse import quote_plus + +from osf.models import NotificationType +from tests.base import OsfTestCase +from osf_tests.factories import ( + AuthUserFactory, + UserFactory, +) +from tests.utils import capture_notifications +from website.util import web_url_for +from tests.test_webtests import assert_in_html, assert_not_in_html + +class TestForgotPassword(OsfTestCase): + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.auth_user = AuthUserFactory() + self.get_url = web_url_for('forgot_password_get') + self.post_url = web_url_for('forgot_password_post') + self.user.verification_key_v2 = {} + self.user.save() + + + # log users out before they land on forgot password page + def test_forgot_password_logs_out_user(self): + # visit forgot password link while another user is logged in + res = self.app.get(self.get_url, auth=self.auth_user.auth) + # check redirection to CAS logout + assert res.status_code == 302 + location = res.headers.get('Location') + assert 'reauth' not in location + assert 'logout?service=' in location + assert 'forgotpassword' in location + + # test that forgot password page is loaded correctly + def test_get_forgot_password(self): + res = self.app.get(self.get_url) + assert res.status_code == 200 + assert 'Forgot Password' in res.text + assert res.get_form('forgotPasswordForm') + + # test that existing user can receive reset password email + def test_can_receive_reset_password_email(self): + # load forgot password page and submit email + res = self.app.get(self.get_url) + form = res.get_form('forgotPasswordForm') + form['forgot_password-email'] = self.user.username + with capture_notifications() as notifications: + res = form.submit(self.app) + # check mail was sent + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_FORGOT_PASSWORD + # check http 200 response + assert res.status_code == 200 + # check request URL is /forgotpassword + assert res.request.path == self.post_url + # check push notification + assert_in_html('If there is an OSF account', res.text) + assert_not_in_html('Please wait', res.text) + + # check verification_key_v2 is set + self.user.reload() + assert self.user.verification_key_v2 != {} + + # test that non-existing user cannot receive reset password email + def test_cannot_receive_reset_password_email(self): + # load forgot password page and submit email + res = self.app.get(self.get_url) + form = res.get_form('forgotPasswordForm') + form['forgot_password-email'] = 'fake' + self.user.username + # mail was not sent + res = form.submit(self.app) + + # check http 200 response + assert res.status_code == 200 + # check request URL is /forgotpassword + assert res.request.path == self.post_url + # check push notification + assert_in_html('If there is an OSF account', res.text) + assert_not_in_html('Please wait', res.text) + + # check verification_key_v2 is not set + self.user.reload() + assert self.user.verification_key_v2 == {} + + # test that non-existing user cannot receive reset password email + def test_not_active_user_no_reset_password_email(self): + self.user.deactivate_account() + self.user.save() + + # load forgot password page and submit email + res = self.app.get(self.get_url) + form = res.get_form('forgotPasswordForm') + form['forgot_password-email'] = self.user.username + res = form.submit(self.app) + + # check http 200 response + assert res.status_code == 200 + # check request URL is /forgotpassword + assert res.request.path == self.post_url + # check push notification + assert_in_html('If there is an OSF account', res.text) + assert_not_in_html('Please wait', res.text) + + # check verification_key_v2 is not set + self.user.reload() + assert self.user.verification_key_v2 == {} + + # test that user cannot submit forgot password request too quickly + def test_cannot_reset_password_twice_quickly(self): + # load forgot password page and submit email + res = self.app.get(self.get_url) + form = res.get_form('forgotPasswordForm') + form['forgot_password-email'] = self.user.username + with capture_notifications(): + res = form.submit(self.app) + res = form.submit(self.app) + + # check http 200 response + assert res.status_code == 200 + # check push notification + assert_in_html('Please wait', res.text) + assert_not_in_html('If there is an OSF account', res.text) + + +class TestForgotPasswordInstitution(OsfTestCase): + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.auth_user = AuthUserFactory() + self.get_url = web_url_for('redirect_unsupported_institution') + self.post_url = web_url_for('forgot_password_institution_post') + self.user.verification_key_v2 = {} + self.user.save() + + + # log users out before they land on institutional forgot password page + def test_forgot_password_logs_out_user(self): + # TODO: check in qa url encoding + # visit forgot password link while another user is logged in + res = self.app.get(self.get_url, auth=self.auth_user.auth) + # check redirection to CAS logout + assert res.status_code == 302 + location = res.headers.get('Location') + assert quote_plus('campaign=unsupportedinstitution') in location + assert 'logout?service=' in location + + # test that institutional forgot password page redirects to CAS unsupported + # institution page + def test_get_forgot_password(self): + res = self.app.get(self.get_url) + assert res.status_code == 302 + location = res.headers.get('Location') + assert 'campaign=unsupportedinstitution' in location + + # test that user from disabled institution can receive reset password email + def test_can_receive_reset_password_email(self): + # submit email to institutional forgot-password page + + with capture_notifications() as notifications: + res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) + + # check mail was sent + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_FORGOT_PASSWORD_INSTITUTION + # check http 200 response + assert res.status_code == 200 + # check request URL is /forgotpassword + assert res.request.path == self.post_url + # check push notification + assert_in_html('If there is an OSF account', res.text) + assert_not_in_html('Please wait', res.text) + + # check verification_key_v2 is set + self.user.reload() + assert self.user.verification_key_v2 != {} + + # test that non-existing user cannot receive reset password email + def test_cannot_receive_reset_password_email(self): + # load forgot password page and submit email + + # mail was not sent + res = self.app.post(self.post_url, data={'forgot_password-email': 'fake' + self.user.username}) + + # check http 200 response + assert res.status_code == 200 + # check request URL is /forgotpassword-institution + assert res.request.path == self.post_url + # check push notification + assert_in_html('If there is an OSF account', res.text) + assert_not_in_html('Please wait', res.text) + + # check verification_key_v2 is not set + self.user.reload() + assert self.user.verification_key_v2 == {} + + # test that non-existing user cannot receive institutional reset password email + def test_not_active_user_no_reset_password_email(self): + self.user.deactivate_account() + self.user.save() + + res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) + + # check http 200 response + assert res.status_code == 200 + # check request URL is /forgotpassword-institution + assert res.request.path == self.post_url + # check push notification + assert_in_html('If there is an OSF account', res.text) + assert_not_in_html('Please wait', res.text) + + # check verification_key_v2 is not set + self.user.reload() + assert self.user.verification_key_v2 == {} + + # test that user cannot submit forgot password request too quickly + def test_cannot_reset_password_twice_quickly(self): + # submit institutional forgot-password request in rapid succession + with capture_notifications(): + res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) + res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) + + # check http 200 response + assert res.status_code == 200 + # check push notification + assert_in_html('Please wait', res.text) + assert_not_in_html('If there is an OSF account', res.text) + diff --git a/tests/test_misc_views.py b/tests/test_misc_views.py index 814ab0556f1..56c804f794f 100644 --- a/tests/test_misc_views.py +++ b/tests/test_misc_views.py @@ -21,7 +21,7 @@ Comment, OSFUser, SpamStatus, - NodeRelation, + NodeRelation, NotificationType, ) from osf.utils import permissions from osf_tests.factories import ( @@ -49,7 +49,7 @@ from website.project.views.node import _should_show_wiki_widget from website.util import web_url_for from website.util import rubeus -from conftest import start_mock_send_grid +from tests.utils import capture_notifications pytestmark = pytest.mark.django_db @@ -361,8 +361,6 @@ def test_explore(self): assert res.status_code == 200 -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestExternalAuthViews(OsfTestCase): def setUp(self): @@ -384,8 +382,6 @@ def setUp(self): self.user.save() self.auth = (self.user.username, password) - self.mock_send_grid = start_mock_send_grid(self) - def test_external_login_email_get_with_invalid_session(self): url = web_url_for('external_login_email_get') resp = self.app.get(url) @@ -414,8 +410,6 @@ def test_external_login_confirm_email_get_create(self): assert '/login?service=' in res.location assert quote_plus('new=true') in res.location - assert self.mock_send_grid.call_count == 0 - self.user.reload() assert self.user.external_identity['orcid'][self.provider_id] == 'VERIFIED' assert self.user.is_registered @@ -426,14 +420,15 @@ def test_external_login_confirm_email_get_link(self): self.user.save() assert not self.user.is_registered url = self.user.get_confirmation_url(self.user.username, external_id_provider='orcid', destination='dashboard') - res = self.app.get(url) + with capture_notifications() as notifications: + res = self.app.get(url) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_EXTERNAL_LOGIN_LINK_SUCCESS assert res.status_code == 302, 'redirects to cas login' assert 'You should be redirected automatically' in str(res.html) assert '/login?service=' in res.location assert 'new=true' not in parse.unquote(res.location) - assert self.mock_send_grid.call_count == 1 - self.user.reload() assert self.user.external_identity['orcid'][self.provider_id] == 'VERIFIED' assert self.user.is_registered @@ -448,8 +443,6 @@ def test_external_login_confirm_email_get_duped_id(self): assert 'You should be redirected automatically' in str(res.html) assert '/login?service=' in res.location - assert self.mock_send_grid.call_count == 0 - self.user.reload() dupe_user.reload() @@ -462,8 +455,6 @@ def test_external_login_confirm_email_get_duping_id(self): res = self.app.get(url) assert res.status_code == 403, 'only allows one user to link an id' - assert self.mock_send_grid.call_count == 0 - self.user.reload() dupe_user.reload() diff --git a/tests/test_notifications.py b/tests/test_notifications.py deleted file mode 100644 index 49c6f1083d2..00000000000 --- a/tests/test_notifications.py +++ /dev/null @@ -1,1174 +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 - -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.NotificationSubscriptionFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).add_user_to_subscription(user, 'none') - - 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 = NotificationSubscription.objects.get(_id=node._id + '_file_updated') - - 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.NotificationSubscriptionFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).add_user_to_subscription(user, 'none') - - 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.NotificationSubscriptionFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).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 = NotificationSubscription.objects.get(_id=node._id + '_file_updated') - - 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.NotificationSubscriptionFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).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 = NotificationSubscription.objects.get(_id=node._id + '_file_updated') - project_file_updated_subscription = NotificationSubscription.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.NotificationSubscriptionFactory( - _id=user._id + '_' + 'global_file_updated', - user=user, - event_name='global_file_updated' - ).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.NotificationSubscriptionFactory( - _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 = NotificationSubscription.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 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 = NotificationSubscription.objects.filter(email_transactional=project.creator) - assert s.count() == 1 - - s = NotificationSubscription.objects.filter(email_transactional=component.creator) - assert s.count() == 1 - - 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 = NotificationSubscription.objects.filter(email_transactional=project.creator) - assert s.count() == 0 - - s = NotificationSubscription.objects.filter(email_transactional=component.creator) - assert s.count() == 0 - - with pytest.raises(NotificationSubscription.DoesNotExist): - NotificationSubscription.objects.get(node=project) - - with pytest.raises(NotificationSubscription.DoesNotExist): - NotificationSubscription.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.NotificationSubscriptionFactory( - _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_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.NotificationSubscriptionFactory( - _id=self.base_project._id + '_file_updated', - node=self.base_project, - event_name='file_updated' - ) - self.base_sub.save() - self.shared_sub = factories.NotificationSubscriptionFactory( - _id=self.shared_node._id + '_file_updated', - node=self.shared_node, - event_name='file_updated' - ) - self.shared_sub.save() - self.private_sub = factories.NotificationSubscriptionFactory( - _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.NotificationSubscriptionFactory( - _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.NotificationSubscriptionFactory( - _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.NotificationSubscriptionFactory( - _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( - _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.node = factories.NodeFactory(parent=self.project) - - - 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.NotificationSubscriptionFactory( - _id=self.user._id + '_' + 'global_comments', - user=self.user, - event_name='global_comments' - ).add_user_to_subscription(self.user, 'email_transactional') - - factories.NotificationSubscriptionFactory( - _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( - _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 = NotificationSubscription.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 = NotificationSubscription.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 = NotificationSubscription.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_pointer_views.py b/tests/test_pointer_views.py index c3fdc6f875b..2f2bbf43f47 100644 --- a/tests/test_pointer_views.py +++ b/tests/test_pointer_views.py @@ -12,6 +12,7 @@ from tests.base import ( OsfTestCase, ) +from tests.utils import capture_notifications from website.project.views.node import abbrev_authors from website.util import web_url_for @@ -255,7 +256,8 @@ def test_forking_pointer_works(self): linked_node = NodeFactory(creator=self.user) pointer = self.project.add_pointer(linked_node, auth=self.consolidate_auth) assert linked_node.id == pointer.child.id - res = self.app.post(url, json={'nodeId': pointer.child._id}, auth=self.user.auth) + with capture_notifications(): + res = self.app.post(url, json={'nodeId': pointer.child._id}, auth=self.user.auth) assert res.status_code == 201 assert 'node' in res.json['data'] fork = res.json['data']['node'] @@ -361,7 +363,8 @@ def test_get_pointed_private(self): def test_can_template_project_linked_to_each_other(self): project2 = ProjectFactory(creator=self.user) self.project.add_pointer(project2, auth=Auth(user=self.user)) - template = self.project.use_as_template(auth=Auth(user=self.user)) + with capture_notifications(): + template = self.project.use_as_template(auth=Auth(user=self.user)) assert template assert template.title == 'Templated from ' + self.project.title diff --git a/tests/test_preprints.py b/tests/test_preprints.py index 13d44d362b5..cc029d4489c 100644 --- a/tests/test_preprints.py +++ b/tests/test_preprints.py @@ -26,7 +26,7 @@ from addons.base import views from admin_tests.utilities import setup_view from api.preprints.views import PreprintContributorDetail -from osf.models import Tag, Preprint, PreprintLog, PreprintContributor +from osf.models import Tag, Preprint, PreprintLog, PreprintContributor, NotificationType from osf.exceptions import PreprintStateError, ValidationError, ValidationValueError from osf_tests.factories import ( ProjectFactory, @@ -43,8 +43,8 @@ from osf.utils.permissions import READ, WRITE, ADMIN from osf.utils.workflows import DefaultStates, RequestTypes, ReviewStates from tests.base import assert_datetime_equal, OsfTestCase -from tests.utils import assert_preprint_logs -from website import settings, mails +from tests.utils import assert_preprint_logs, capture_notifications +from website import settings from website.identifiers.clients import CrossRefClient, ECSArXivCrossRefClient, crossref from website.identifiers.utils import request_identifiers from website.preprints.tasks import ( @@ -53,8 +53,6 @@ update_or_enqueue_on_preprint_updated, should_update_preprint_identifiers ) -from conftest import start_mock_send_grid - SessionStore = import_module(django_conf_settings.SESSION_ENGINE).SessionStore @@ -332,13 +330,14 @@ def test_add_contributor(self, preprint, user, auth): def test_add_contributors(self, preprint, auth): user1 = UserFactory() user2 = UserFactory() - preprint.add_contributors( - [ - {'user': user1, 'permissions': ADMIN, 'visible': True}, - {'user': user2, 'permissions': WRITE, 'visible': False} - ], - auth=auth - ) + with capture_notifications(): + preprint.add_contributors( + [ + {'user': user1, 'permissions': ADMIN, 'visible': True}, + {'user': user2, 'permissions': WRITE, 'visible': False} + ], + auth=auth, + ) last_log = preprint.logs.all().order_by('-created')[0] assert ( last_log.params['contributors'] == @@ -467,13 +466,14 @@ def test_remove_contributor(self, preprint, auth): def test_remove_contributors(self, preprint, auth): user1 = UserFactory() user2 = UserFactory() - preprint.add_contributors( - [ - {'user': user1, 'permissions': WRITE, 'visible': True}, - {'user': user2, 'permissions': WRITE, 'visible': True} - ], - auth=auth - ) + with capture_notifications(): + preprint.add_contributors( + [ + {'user': user1, 'permissions': WRITE, 'visible': True}, + {'user': user2, 'permissions': WRITE, 'visible': True} + ], + auth=auth + ) assert user1 in preprint.contributors assert user2 in preprint.contributors assert preprint.has_permission(user1, WRITE) @@ -570,36 +570,40 @@ class TestPreprintAddContributorRegisteredOrNot: def test_add_contributor_user_id(self, user, preprint): registered_user = UserFactory() - contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), user_id=registered_user._id, save=True) + with capture_notifications(): + contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), user_id=registered_user._id) contributor = contributor_obj.user assert contributor in preprint.contributors assert contributor.is_registered is True def test_add_contributor_user_id_already_contributor(self, user, preprint): with pytest.raises(ValidationError) as excinfo: - preprint.add_contributor_registered_or_not(auth=Auth(user), user_id=user._id, save=True) + preprint.add_contributor_registered_or_not(auth=Auth(user), user_id=user._id) assert 'is already a contributor' in excinfo.value.message def test_add_contributor_invalid_user_id(self, user, preprint): with pytest.raises(ValueError) as excinfo: - preprint.add_contributor_registered_or_not(auth=Auth(user), user_id='abcde', save=True) + preprint.add_contributor_registered_or_not(auth=Auth(user), user_id='abcde') assert 'was not found' in str(excinfo.value) def test_add_contributor_fullname_email(self, user, preprint): - contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe', email='jane@doe.com') + with capture_notifications(): + contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe', email='jane@doe.com') contributor = contributor_obj.user assert contributor in preprint.contributors assert contributor.is_registered is False def test_add_contributor_fullname(self, user, preprint): - contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe') + with capture_notifications(): + contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), full_name='Jane Doe') contributor = contributor_obj.user assert contributor in preprint.contributors assert contributor.is_registered is False def test_add_contributor_fullname_email_already_exists(self, user, preprint): registered_user = UserFactory() - contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), full_name='F Mercury', email=registered_user.username) + with capture_notifications(): + contributor_obj = preprint.add_contributor_registered_or_not(auth=Auth(user), full_name='F Mercury', email=registered_user.username) contributor = contributor_obj.user assert contributor in preprint.contributors assert contributor.is_registered is True @@ -971,7 +975,7 @@ def test_confirm_ham_on_public_preprint_stays_public(self, preprint, user): @mock.patch.object(settings, 'SPAM_SERVICES_ENABLED', True) @mock.patch.object(settings, 'SPAM_ACCOUNT_SUSPENSION_ENABLED', True) @pytest.mark.skip('Technically still true, but skipping because mocking is outdated') - def test_check_spam_on_private_preprint_bans_new_spam_user(self, mock_send_mail, preprint, user): + def test_check_spam_on_private_preprint_bans_new_spam_user(self, preprint, user): preprint.is_public = False preprint.save() with mock.patch('osf.models.Preprint._get_spam_content', mock.Mock(return_value='some content!')): @@ -998,10 +1002,10 @@ def test_check_spam_on_private_preprint_bans_new_spam_user(self, mock_send_mail, preprint3.reload() assert preprint3.is_public is True - @mock.patch('website.mailchimp_utils.unsubscribe_mailchimp') @mock.patch.object(settings, 'SPAM_SERVICES_ENABLED', True) @mock.patch.object(settings, 'SPAM_ACCOUNT_SUSPENSION_ENABLED', True) - def test_check_spam_on_private_preprint_does_not_ban_existing_user(self, mock_send_mail, preprint, user): + @mock.patch('website.mailchimp_utils.unsubscribe_mailchimp') + def test_check_spam_on_private_preprint_does_not_ban_existing_user(self, mock_mailchimp, preprint, user): preprint.is_public = False preprint.save() with mock.patch('osf.models.Preprint._get_spam_content', mock.Mock(return_value='some content!')): @@ -1066,13 +1070,13 @@ def test_contributor_manage_visibility(self, preprint, user, auth): def test_contributor_set_visibility_validation(self, preprint, user, auth): reg_user1, reg_user2 = UserFactory(), UserFactory() - preprint.add_contributors( - [ - {'user': reg_user1, 'permissions': ADMIN, 'visible': True}, - {'user': reg_user2, 'permissions': ADMIN, 'visible': False}, - ] - ) - print(preprint.visible_contributor_ids) + with capture_notifications(): + preprint.add_contributors( + [ + {'user': reg_user1, 'permissions': ADMIN, 'visible': True}, + {'user': reg_user2, 'permissions': ADMIN, 'visible': False}, + ] + ) with pytest.raises(ValueError) as e: preprint.set_visible(user=reg_user1, visible=False, auth=None) preprint.set_visible(user=user, visible=False, auth=None) @@ -1272,13 +1276,14 @@ def test_can_set_contributor_order(self, preprint): def test_move_contributor(self, user, preprint, auth): user1 = UserFactory() user2 = UserFactory() - preprint.add_contributors( - [ - {'user': user1, 'permissions': WRITE, 'visible': True}, - {'user': user2, 'permissions': WRITE, 'visible': True} - ], - auth=auth - ) + with capture_notifications(): + preprint.add_contributors( + [ + {'user': user1, 'permissions': WRITE, 'visible': True}, + {'user': user2, 'permissions': WRITE, 'visible': True} + ], + auth=auth + ) user_contrib_id = preprint.preprintcontributor_set.get(user=user).id user1_contrib_id = preprint.preprintcontributor_set.get(user=user1).id @@ -1417,7 +1422,8 @@ def test_is_preprint_property_new_file_to_published(self): self.preprint.set_subjects([[SubjectFactory()._id]], auth=self.auth) self.preprint.reload() assert not self.preprint.is_published - self.preprint.set_published(True, auth=self.auth, save=True) + with capture_notifications(): + self.preprint.set_published(True, auth=self.auth, save=True) self.preprint.reload() assert self.preprint.is_published @@ -1456,7 +1462,8 @@ def test_preprint_made_public(self): self.preprint.set_subjects([[SubjectFactory()._id]], auth=self.auth) self.preprint.reload() assert not self.preprint.is_public - self.preprint.set_published(True, auth=self.auth, save=True) + with capture_notifications(): + self.preprint.set_published(True, auth=self.auth, save=True) self.project.reload() assert self.preprint.is_public @@ -1492,18 +1499,20 @@ def test_preprint_created_date(self): assert self.project.created != self.preprint.created def test_cant_save_without_file(self): - self.preprint.set_primary_file(self.file, auth=self.auth, save=True) - self.preprint.set_subjects([[SubjectFactory()._id]], auth=self.auth) - self.preprint.set_published(True, auth=self.auth, save=False) + with capture_notifications(): + self.preprint.set_primary_file(self.file, auth=self.auth, save=True) + self.preprint.set_subjects([[SubjectFactory()._id]], auth=self.auth) + self.preprint.set_published(True, auth=self.auth, save=False) self.preprint.primary_file = None with pytest.raises(ValidationError): self.preprint.save() def test_cant_update_without_file(self): - self.preprint.set_primary_file(self.file, auth=self.auth, save=True) - self.preprint.set_subjects([[SubjectFactory()._id]], auth=self.auth) - self.preprint.set_published(True, auth=self.auth, save=True) + with capture_notifications(): + self.preprint.set_primary_file(self.file, auth=self.auth, save=True) + self.preprint.set_subjects([[SubjectFactory()._id]], auth=self.auth) + self.preprint.set_published(True, auth=self.auth, save=True) self.preprint.primary_file = None with pytest.raises(ValidationError): self.preprint.save() @@ -1620,14 +1629,16 @@ def test_write_cannot_publish(self): def test_admin_can_publish(self): assert not self.preprint.is_published - self.preprint.set_published(True, auth=Auth(self.user), save=True) + with capture_notifications(): + self.preprint.set_published(True, auth=Auth(self.user), save=True) assert self.preprint.is_published def test_admin_cannot_unpublish(self): assert not self.preprint.is_published - self.preprint.set_published(True, auth=Auth(self.user), save=True) + with capture_notifications(): + self.preprint.set_published(True, auth=Auth(self.user), save=True) assert self.preprint.is_published @@ -1985,8 +1996,6 @@ def test_update_or_enqueue_on_preprint_doi_created(self): assert should_update_preprint_identifiers(self.private_preprint, {}) -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class TestPreprintConfirmationEmails(OsfTestCase): def setUp(self): super().setUp() @@ -1996,17 +2005,17 @@ def setUp(self): self.preprint = PreprintFactory(creator=self.user, project=self.project, provider=PreprintProviderFactory(_id='osf'), is_published=False) self.preprint.add_contributor(self.write_contrib, permissions=WRITE) self.preprint_branded = PreprintFactory(creator=self.user, is_published=False) - self.mock_send_grid = start_mock_send_grid(self) def test_creator_gets_email(self): - self.preprint.set_published(True, auth=Auth(self.user), save=True) - domain = self.preprint.provider.domain or settings.DOMAIN - self.mock_send_grid.assert_called() - assert self.mock_send_grid.call_count == 1 - - self.preprint_branded.set_published(True, auth=Auth(self.user), save=True) - assert self.mock_send_grid.call_count == 2 + with capture_notifications() as notifications: + self.preprint.set_published(True, auth=Auth(self.user), save=True) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION + with capture_notifications() as notifications: + self.preprint_branded.set_published(True, auth=Auth(self.user), save=True) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.PROVIDER_REVIEWS_SUBMISSION_CONFIRMATION class TestPreprintOsfStorage(OsfTestCase): def setUp(self): @@ -2165,7 +2174,8 @@ def test_add_log(self): url = self.preprint.api_url_for('create_waterbutler_log') payload = self.build_payload(metadata={'nid': self.preprint._id, 'materialized': path, 'kind': 'file', 'path': path}) nlogs = self.preprint.logs.count() - self.app.put(url, json=payload) + with capture_notifications(): + self.app.put(url, json=payload) self.preprint.reload() assert self.preprint.logs.count() == nlogs + 1 @@ -2234,10 +2244,11 @@ def test_action_file_rename(self): 'kind': 'file', }, ) - self.app.put( - url, - json=payload, - ) + with capture_notifications(): + self.app.put( + url, + json=payload, + ) self.preprint.reload() assert self.preprint.logs.latest().action == 'osf_storage_addon_file_renamed' @@ -2267,7 +2278,8 @@ def test_add_file_osfstorage_log(self): url = self.preprint.api_url_for('create_waterbutler_log') payload = self.build_payload(metadata={'nid': self.preprint._id, 'materialized': path, 'kind': 'file', 'path': path}) nlogs = self.preprint.logs.count() - self.app.put(url, json=payload) + with capture_notifications(): + self.app.put(url, json=payload) self.preprint.reload() assert self.preprint.logs.count() == nlogs + 1 assert ('urls' in self.preprint.logs.filter(action='osf_storage_file_added')[0].params) @@ -2277,7 +2289,8 @@ def test_add_folder_osfstorage_log(self): url = self.preprint.api_url_for('create_waterbutler_log') payload = self.build_payload(metadata={'nid': self.preprint._id, 'materialized': path, 'kind': 'folder', 'path': path}) nlogs = self.preprint.logs.count() - self.app.put(url, json=payload) + with capture_notifications(): + self.app.put(url, json=payload) self.preprint.reload() assert self.preprint.logs.count() == nlogs + 1 assert ('urls' not in self.preprint.logs.filter(action='osf_storage_file_added')[0].params) @@ -2352,7 +2365,8 @@ def test_withdrawn_preprint(self, user, preprint, unpublished_preprint_pre_mod, assert preprint.ever_public # pre-mod - unpublished_preprint_pre_mod.run_submit(user) + with capture_notifications(): + unpublished_preprint_pre_mod.run_submit(user) assert not unpublished_preprint_pre_mod.ever_public unpublished_preprint_pre_mod.run_reject(user, 'it') @@ -2363,7 +2377,8 @@ def test_withdrawn_preprint(self, user, preprint, unpublished_preprint_pre_mod, assert unpublished_preprint_pre_mod.ever_public # post-mod - unpublished_preprint_post_mod.run_submit(user) + with capture_notifications(): + unpublished_preprint_post_mod.run_submit(user) assert unpublished_preprint_post_mod.ever_public # test_cannot_set_ever_public_to_False @@ -2383,7 +2398,8 @@ def test_crossref_status_is_updated(self, make_withdrawal_request, preprint, pre assert crossref_client.get_status(preprint) == 'public' withdrawal_request = make_withdrawal_request(preprint) - withdrawal_request.run_accept(admin, withdrawal_request.comment) + with capture_notifications(): + withdrawal_request.run_accept(admin, withdrawal_request.comment) assert preprint.is_retracted assert preprint.verified_publishable @@ -2394,7 +2410,8 @@ def test_crossref_status_is_updated(self, make_withdrawal_request, preprint, pre assert crossref_client.get_status(preprint_post_mod) == 'public' withdrawal_request = make_withdrawal_request(preprint_post_mod) - withdrawal_request.run_accept(moderator, withdrawal_request.comment) + with capture_notifications(): + withdrawal_request.run_accept(moderator, withdrawal_request.comment) assert preprint_post_mod.is_retracted assert preprint_post_mod.verified_publishable @@ -2405,7 +2422,8 @@ def test_crossref_status_is_updated(self, make_withdrawal_request, preprint, pre assert crossref_client.get_status(preprint_pre_mod) == 'public' withdrawal_request = make_withdrawal_request(preprint_pre_mod) - withdrawal_request.run_accept(moderator, withdrawal_request.comment) + with capture_notifications(): + withdrawal_request.run_accept(moderator, withdrawal_request.comment) assert preprint_pre_mod.is_retracted assert preprint_pre_mod.verified_publishable @@ -2460,7 +2478,8 @@ def test_unpublished_preprint_pre_mod_accept(self, unpublished_preprint_pre_mod, assert unpublished_preprint_pre_mod.is_published is False assert unpublished_preprint_pre_mod.machine_state == ReviewStates.INITIAL.value - unpublished_preprint_pre_mod.run_submit(creator) + with capture_notifications(): + unpublished_preprint_pre_mod.run_submit(creator) assert unpublished_preprint_pre_mod.is_published is False assert unpublished_preprint_pre_mod.machine_state == ReviewStates.PENDING.value guid_obj = unpublished_preprint_pre_mod.get_guid() @@ -2480,7 +2499,8 @@ def test_unpublished_preprint_pre_mod_reject(self, unpublished_preprint_pre_mod, assert unpublished_preprint_pre_mod.is_published is False assert unpublished_preprint_pre_mod.machine_state == ReviewStates.INITIAL.value - unpublished_preprint_pre_mod.run_submit(creator) + with capture_notifications(): + unpublished_preprint_pre_mod.run_submit(creator) assert unpublished_preprint_pre_mod.is_published is False assert unpublished_preprint_pre_mod.machine_state == ReviewStates.PENDING.value guid_obj = unpublished_preprint_pre_mod.get_guid() @@ -2514,7 +2534,8 @@ def test_unpublished_version_pre_mod_submit_and_accept(self, preprint_pre_mod, c assert guid_obj.referent == preprint_pre_mod assert guid_obj.content_type == ContentType.objects.get_for_model(Preprint) - new_version.run_submit(creator) + with capture_notifications(): + new_version.run_submit(creator) assert new_version.is_published is False assert new_version.machine_state == ReviewStates.PENDING.value guid_obj = new_version.get_guid() @@ -2548,7 +2569,8 @@ def test_new_version_pre_mod_submit_and_reject(self, preprint_pre_mod, creator, assert guid_obj.referent == preprint_pre_mod assert guid_obj.content_type == ContentType.objects.get_for_model(Preprint) - new_version.run_submit(creator) + with capture_notifications(): + new_version.run_submit(creator) assert new_version.is_published is False assert new_version.machine_state == ReviewStates.PENDING.value guid_obj = new_version.get_guid() @@ -2568,7 +2590,8 @@ def test_unpublished_preprint_post_mod_submit_and_accept(self, unpublished_prepr assert unpublished_preprint_post_mod.is_published is False assert unpublished_preprint_post_mod.machine_state == ReviewStates.INITIAL.value - unpublished_preprint_post_mod.run_submit(creator) + with capture_notifications(): + unpublished_preprint_post_mod.run_submit(creator) assert unpublished_preprint_post_mod.is_published is True assert unpublished_preprint_post_mod.machine_state == ReviewStates.PENDING.value guid_obj = unpublished_preprint_post_mod.get_guid() @@ -2601,7 +2624,8 @@ def test_unpublished_new_version_post_mod_submit_and_accept(self, preprint_post_ assert guid_obj.referent == preprint_post_mod assert guid_obj.content_type == ContentType.objects.get_for_model(Preprint) - new_version.run_submit(creator) + with capture_notifications(): + new_version.run_submit(creator) assert new_version.is_published is True assert new_version.machine_state == ReviewStates.PENDING.value guid_obj = new_version.get_guid() @@ -2625,7 +2649,8 @@ def test_preprint_withdrawal_request_pre_mod(self, make_withdrawal_request, mode assert withdrawal_request.machine_state == DefaultStates.PENDING.value assert preprint_pre_mod.is_published is True assert preprint_pre_mod.machine_state == ReviewStates.ACCEPTED.value - withdrawal_request.run_accept(moderator, 'comment') + with capture_notifications(): + withdrawal_request.run_accept(moderator, 'comment') preprint_pre_mod.reload() assert withdrawal_request.machine_state == DefaultStates.ACCEPTED.value # In model, `is_published` remains True; this is different than API where `is_published` uses `NoneIfWithdrawal` @@ -2641,7 +2666,8 @@ def test_preprint_withdrawal_request_post_mod(self, make_withdrawal_request, mod assert withdrawal_request.machine_state == DefaultStates.PENDING.value assert preprint_post_mod.is_published is True assert preprint_post_mod.machine_state == ReviewStates.ACCEPTED.value - withdrawal_request.run_accept(moderator, 'comment') + with capture_notifications(): + withdrawal_request.run_accept(moderator, 'comment') preprint_post_mod.reload() assert withdrawal_request.machine_state == DefaultStates.ACCEPTED.value # In model, `is_published` remains True; this is different than API where `is_published` uses `NoneIfWithdrawal` @@ -2657,8 +2683,9 @@ def test_preprint_version_withdrawal_request_pre_mod(self, make_withdrawal_reque is_published=False, set_doi=False ) - new_version.run_submit(creator) - new_version.run_accept(moderator, 'comment') + with capture_notifications(): + new_version.run_submit(creator) + new_version.run_accept(moderator, 'comment') new_version.reload() assert new_version.is_published is True assert new_version.machine_state == ReviewStates.ACCEPTED.value @@ -2667,7 +2694,8 @@ def test_preprint_version_withdrawal_request_pre_mod(self, make_withdrawal_reque assert withdrawal_request.machine_state == DefaultStates.PENDING.value assert new_version.is_published is True assert new_version.machine_state == ReviewStates.ACCEPTED.value - withdrawal_request.run_accept(moderator, 'comment') + with capture_notifications(): + withdrawal_request.run_accept(moderator, 'comment') new_version.reload() assert withdrawal_request.machine_state == DefaultStates.ACCEPTED.value # In model, `is_published` remains True; this is different than API where `is_published` uses `NoneIfWithdrawal` @@ -2683,8 +2711,9 @@ def test_preprint_version_withdrawal_request_post_mod(self, make_withdrawal_requ is_published=False, set_doi=False ) - new_version.run_submit(creator) - new_version.run_accept(moderator, 'comment') + with capture_notifications(): + new_version.run_submit(creator) + new_version.run_accept(moderator, 'comment') new_version.reload() assert new_version.is_published is True assert new_version.machine_state == ReviewStates.ACCEPTED.value @@ -2693,7 +2722,8 @@ def test_preprint_version_withdrawal_request_post_mod(self, make_withdrawal_requ assert withdrawal_request.machine_state == DefaultStates.PENDING.value assert new_version.is_published is True assert new_version.machine_state == ReviewStates.ACCEPTED.value - withdrawal_request.run_accept(moderator, 'comment') + with capture_notifications(): + withdrawal_request.run_accept(moderator, 'comment') new_version.reload() assert withdrawal_request.machine_state == DefaultStates.ACCEPTED.value # In model, `is_published` remains True; this is different than API where `is_published` uses `NoneIfWithdrawal` diff --git a/tests/test_project_contibutor_views.py b/tests/test_project_contibutor_views.py index 4b33c890784..418fb13703b 100644 --- a/tests/test_project_contibutor_views.py +++ b/tests/test_project_contibutor_views.py @@ -25,6 +25,7 @@ fake, OsfTestCase, ) +from tests.utils import capture_notifications from website import language from website.profile.utils import add_contributor_json @@ -188,16 +189,17 @@ def test_add_contributor_post(self): } ) - self.app.post( - f'/api/v1/project/{project._id}/contributors/', - json={ - 'users': [dict2, dict3], - 'node_ids': [project._id], - }, - content_type='application/json', - auth=self.auth, - follow_redirects=True, - ) + with capture_notifications(): + self.app.post( + f'/api/v1/project/{project._id}/contributors/', + json={ + 'users': [dict2, dict3], + 'node_ids': [project._id], + }, + content_type='application/json', + auth=self.auth, + follow_redirects=True, + ) project.reload() assert user2 in project.contributors # A log event was added @@ -318,17 +320,18 @@ def test_contributor_manage_reorder(self): # Two users are added as a contributor via a POST request project = ProjectFactory(creator=self.user1, is_public=True) reg_user1, reg_user2 = UserFactory(), UserFactory() - project.add_contributors( - [ - {'user': reg_user1, 'permissions': permissions.ADMIN, 'visible': True}, - {'user': reg_user2, 'permissions': permissions.ADMIN, 'visible': False}, - ] - ) + with capture_notifications(): + project.add_contributors( + [ + {'user': reg_user1, 'permissions': permissions.ADMIN, 'visible': True}, + {'user': reg_user2, 'permissions': permissions.ADMIN, 'visible': False}, + ] + ) # Add a non-registered user unregistered_user = project.add_unregistered_contributor( - fullname=fake.name(), email=fake_email(), + fullname=fake.name(), + email=fake_email(), auth=self.consolidate_auth1, - save=True, ) url = project.api_url + 'contributors/manage/' @@ -534,27 +537,27 @@ def test_get_contributors_abbrev(self): # create a project with 3 registered contributors project = ProjectFactory(creator=self.user1, is_public=True) reg_user1, reg_user2 = UserFactory(), UserFactory() - project.add_contributors( - [ - { - 'user': reg_user1, - 'permissions': permissions.ADMIN, - 'visible': True - }, - { - 'user': reg_user2, - 'permissions': permissions.ADMIN, - 'visible': True - }, - ] - ) + with capture_notifications(): + project.add_contributors( + [ + { + 'user': reg_user1, + 'permissions': permissions.ADMIN, + 'visible': True + }, + { + 'user': reg_user2, + 'permissions': permissions.ADMIN, + 'visible': True + }, + ] + ) # add an unregistered contributor project.add_unregistered_contributor( fullname='Jalen Hurts', email='gobirds@eagle.fly', auth=self.consolidate_auth1, - save=True, ) res = self.app.get( diff --git a/tests/test_project_creation_view.py b/tests/test_project_creation_view.py index da6fa8ac76a..977077e0d51 100644 --- a/tests/test_project_creation_view.py +++ b/tests/test_project_creation_view.py @@ -13,6 +13,7 @@ from tests.base import ( OsfTestCase, ) +from tests.utils import capture_notifications from website.util import api_url_for, web_url_for @pytest.mark.enable_implicit_clean @@ -210,7 +211,8 @@ def test_can_template(self): 'title': 'Im a real title', 'template': other_node._id } - res = self.app.post(self.url, json=payload, auth=self.creator.auth) + with capture_notifications(): + res = self.app.post(self.url, json=payload, auth=self.creator.auth) assert res.status_code == 201 node = AbstractNode.load(res.json['projectUrl'].replace('/', '')) assert node @@ -239,7 +241,8 @@ def test_project_new_from_template_public_non_contributor(self): non_contributor = AuthUserFactory() project = ProjectFactory(is_public=True) url = api_url_for('project_new_from_template', nid=project._id) - res = self.app.post(url, auth=non_contributor.auth) + with capture_notifications(): + res = self.app.post(url, auth=non_contributor.auth) assert res.status_code == 201 def test_project_new_from_template_contributor(self): @@ -249,5 +252,6 @@ def test_project_new_from_template_contributor(self): project.save() url = api_url_for('project_new_from_template', nid=project._id) - res = self.app.post(url, auth=contributor.auth) + with capture_notifications(): + res = self.app.post(url, auth=contributor.auth) assert res.status_code == 201 diff --git a/tests/test_project_views.py b/tests/test_project_views.py index c43230c4887..9b02f86ac9f 100644 --- a/tests/test_project_views.py +++ b/tests/test_project_views.py @@ -20,6 +20,7 @@ RegistrationFactory, ) from tests.base import OsfTestCase +from tests.utils import capture_notifications @pytest.mark.enable_bookmark_creation @@ -359,7 +360,8 @@ def test_fork_grandcomponents_has_correct_root(self): grand_child = NodeFactory(parent=child, creator=user) project.save() - fork = project.fork_node(auth) + with capture_notifications(): + fork = project.fork_node(auth) fork.save() grand_child_fork = fork.nodes[0].nodes[0] assert grand_child_fork.root == fork @@ -368,7 +370,8 @@ def test_fork_count_does_not_include_deleted_forks(self): user = AuthUserFactory() project = ProjectFactory(creator=user) auth = Auth(project.creator) - fork = project.fork_node(auth) + with capture_notifications(): + fork = project.fork_node(auth) project.save() fork.remove_node(auth) @@ -381,7 +384,8 @@ def test_fork_count_does_not_include_fork_registrations(self): user = AuthUserFactory() project = ProjectFactory(creator=user) auth = Auth(project.creator) - fork = project.fork_node(auth) + with capture_notifications(): + fork = project.fork_node(auth) project.save() registration = RegistrationFactory(project=fork) diff --git a/tests/test_registrations/test_embargoes.py b/tests/test_registrations/test_embargoes.py index 4c310eecd79..c58de3c450e 100644 --- a/tests/test_registrations/test_embargoes.py +++ b/tests/test_registrations/test_embargoes.py @@ -29,7 +29,7 @@ from osf.models.sanctions import SanctionCallbackMixin, Embargo from osf.utils import permissions from osf.models import Registration, Contributor, OSFUser, SpamStatus -from conftest import start_mock_send_grid +from tests.utils import capture_notifications DUMMY_TOKEN = tokens.encode({ 'dummy': 'token' @@ -525,275 +525,6 @@ def test_disapproval_cancels_embargo_on_descendant_nodes(self): assert not node.embargo_end_date -@pytest.mark.enable_bookmark_creation -class LegacyRegistrationEmbargoApprovalDisapprovalViewsTestCase(OsfTestCase): - """ - TODO: Remove this set of tests when process_token_or_pass decorator taken - off the view_project view - """ - def setUp(self): - super().setUp() - self.user = AuthUserFactory() - self.project = ProjectFactory(creator=self.user) - self.registration = RegistrationFactory(creator=self.user, project=self.project) - - def test_GET_approve_registration_without_embargo_raises_HTTPBad_Request(self): - assert not self.registration.is_pending_embargo - res = self.app.get( - self.registration.web_url_for('view_project', token=DUMMY_TOKEN), - auth=self.user.auth, - ) - assert res.status_code == 400 - - def test_GET_approve_with_invalid_token_returns_HTTPBad_Request(self): - self.registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10) - ) - self.registration.save() - assert self.registration.is_pending_embargo - - res = self.app.get( - self.registration.web_url_for('view_project', token=DUMMY_TOKEN), - auth=self.user.auth, - ) - assert res.status_code == 400 - - def test_GET_approve_with_wrong_token_returns_HTTPBad_Request(self): - admin2 = UserFactory() - Contributor.objects.create(user=admin2, node=self.registration) - self.registration.add_permission(admin2, permissions.ADMIN, save=True) - self.registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10) - ) - self.registration.save() - assert self.registration.is_pending_embargo - - wrong_approval_token = self.registration.embargo.approval_state[admin2._id]['approval_token'] - res = self.app.get( - self.registration.web_url_for('view_project', token=wrong_approval_token), - auth=self.user.auth, - ) - assert res.status_code == 400 - - def test_GET_approve_with_wrong_admins_token_returns_HTTPBad_Request(self): - admin2 = UserFactory() - Contributor.objects.create(user=admin2, node=self.registration) - self.registration.add_permission(admin2, permissions.ADMIN, save=True) - self.registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10) - ) - self.registration.save() - assert self.registration.is_pending_embargo - - wrong_approval_token = self.registration.embargo.approval_state[admin2._id]['approval_token'] - res = self.app.get( - self.registration.web_url_for('view_project', token=wrong_approval_token), - auth=self.user.auth, - ) - assert self.registration.is_pending_embargo - assert res.status_code == 400 - - @mock.patch('website.project.views.node.redirect') - def test_GET_approve_with_valid_token_redirects(self, mock_redirect): - self.registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10) - ) - self.registration.save() - assert self.registration.is_pending_embargo - - approval_token = self.registration.embargo.approval_state[self.user._id]['approval_token'] - self.app.get( - self.registration.web_url_for('view_project', token=approval_token), - auth=self.user.auth, - ) - self.registration.embargo.reload() - assert self.registration.embargo_end_date - assert not self.registration.is_pending_embargo - mock_redirect.assert_not_called() - - def test_GET_disapprove_registration_without_embargo_HTTPBad_Request(self): - assert not self.registration.is_pending_embargo - res = self.app.get( - self.registration.web_url_for('view_project', token=DUMMY_TOKEN), - auth=self.user.auth, - ) - assert res.status_code == 400 - - def test_GET_disapprove_with_invalid_token_returns_HTTPBad_Request(self): - self.registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10) - ) - self.registration.save() - assert self.registration.is_pending_embargo - - res = self.app.get( - self.registration.web_url_for('view_project', token=DUMMY_TOKEN), - auth=self.user.auth, - ) - self.registration.embargo.reload() - assert self.registration.is_pending_embargo - assert res.status_code == 400 - - def test_GET_disapprove_with_wrong_admins_token_returns_HTTPBad_Request(self): - admin2 = UserFactory() - Contributor.objects.create(user=admin2, node=self.registration) - self.registration.add_permission(admin2, permissions.ADMIN, save=True) - self.registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10) - ) - self.registration.save() - assert self.registration.is_pending_embargo - - wrong_rejection_token = self.registration.embargo.approval_state[admin2._id]['rejection_token'] - res = self.app.get( - self.registration.web_url_for('view_project', token=wrong_rejection_token), - auth=self.user.auth, - ) - assert self.registration.is_pending_embargo - assert res.status_code == 400 - - def test_GET_disapprove_with_valid(self): - project = ProjectFactory(creator=self.user) - registration = RegistrationFactory(project=project) - registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10) - ) - registration.save() - assert registration.is_pending_embargo - - rejection_token = registration.embargo.approval_state[self.user._id]['rejection_token'] - - res = self.app.get( - registration.registered_from.web_url_for('view_project', token=rejection_token), - auth=self.user.auth, - ) - registration.embargo.reload() - assert registration.embargo.state == Embargo.REJECTED - assert not registration.is_pending_embargo - assert res.status_code == 200 - assert project.web_url_for('view_project') == res.request.path - - def test_GET_disapprove_for_existing_registration_returns_200(self): - self.registration.embargo_registration( - self.user, - timezone.now() + datetime.timedelta(days=10), - for_existing_registration=True - ) - self.registration.save() - assert self.registration.is_pending_embargo - - rejection_token = self.registration.embargo.approval_state[self.user._id]['rejection_token'] - res = self.app.get( - self.registration.web_url_for('view_project', token=rejection_token), - auth=self.user.auth, - ) - self.registration.embargo.reload() - assert self.registration.embargo.state == Embargo.REJECTED - assert not self.registration.is_pending_embargo - assert res.status_code == 200 - assert res.request.path == self.registration.web_url_for('view_project') - - @pytest.mark.usefixtures('mock_gravy_valet_get_verified_links') - def test_GET_from_unauthorized_user_with_registration_token(self): - unauthorized_user = AuthUserFactory() - - self.registration.require_approval(self.user) - self.registration.save() - - app_token = self.registration.registration_approval.approval_state[self.user._id]['approval_token'] - rej_token = self.registration.registration_approval.approval_state[self.user._id]['rejection_token'] - - # Test unauth user cannot approve - res = self.app.get( - # approval token goes through registration - self.registration.web_url_for('view_project', token=app_token), - auth=unauthorized_user.auth, - ) - assert res.status_code == 403 - - # Test unauth user cannot reject - res = self.app.get( - # rejection token goes through registration parent - self.project.web_url_for('view_project', token=rej_token), - auth=unauthorized_user.auth, - ) - assert res.status_code == 403 - - # Delete Node and try again - self.project.is_deleted = True - self.project.save() - - # Test unauth user cannot approve deleted node - res = self.app.get( - self.registration.web_url_for('view_project', token=app_token), - auth=unauthorized_user.auth, - ) - assert res.status_code == 403 - - # Test unauth user cannot reject - res = self.app.get( - self.project.web_url_for('view_project', token=rej_token), - auth=unauthorized_user.auth, - ) - assert res.status_code == 403 - - # Test auth user can approve registration with deleted parent - res = self.app.get( - self.registration.web_url_for('view_project', token=app_token), - auth=self.user.auth, - ) - assert res.status_code == 200 - - @pytest.mark.usefixtures('mock_gravy_valet_get_verified_links') - def test_GET_from_authorized_user_with_registration_app_token(self): - self.registration.require_approval(self.user) - self.registration.save() - app_token = self.registration.registration_approval.approval_state[self.user._id]['approval_token'] - - res = self.app.get( - self.registration.web_url_for('view_project', token=app_token), - auth=self.user.auth, - ) - assert res.status_code == 200 - - def test_GET_from_authorized_user_with_registration_rej_token(self): - self.registration.require_approval(self.user) - self.registration.save() - rej_token = self.registration.registration_approval.approval_state[self.user._id]['rejection_token'] - - res = self.app.get( - self.project.web_url_for('view_project', token=rej_token), - auth=self.user.auth, - ) - assert res.status_code == 200 - - def test_GET_from_authorized_user_with_registration_rej_token_deleted_node(self): - self.registration.require_approval(self.user) - self.registration.save() - rej_token = self.registration.registration_approval.approval_state[self.user._id]['rejection_token'] - - self.project.is_deleted = True - self.project.save() - - res = self.app.get( - self.project.web_url_for('view_project', token=rej_token), - auth=self.user.auth, - ) - assert res.status_code == 410 - res = self.app.get( - self.registration.web_url_for('view_project'), - auth=self.user.auth, - ) - assert res.status_code == 410 - - @pytest.mark.enable_bookmark_creation class RegistrationEmbargoApprovalDisapprovalViewsTestCase(OsfTestCase): def setUp(self): @@ -1060,8 +791,6 @@ def test_GET_from_authorized_user_with_registration_rej_token_deleted_node(self) @pytest.mark.enable_bookmark_creation -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class RegistrationEmbargoViewsTestCase(OsfTestCase): def setUp(self): super().setUp() @@ -1101,9 +830,6 @@ def setUp(self): } }) - self.mock_send_grid = start_mock_send_grid(self) - - @mock.patch('osf.models.sanctions.EmailApprovableSanction.ask') def test_embargoed_registration_set_privacy_requests_embargo_termination(self, mock_ask): # Initiate and approve embargo @@ -1154,13 +880,14 @@ def test_embargoed_registration_set_privacy_sends_mail(self): self.registration.embargo.approve_embargo(OSFUser.load(user_id), approval_token) self.registration.refresh_from_db() - self.registration.set_privacy('public', Auth(self.registration.creator)) + with capture_notifications() as notifications: + self.registration.set_privacy('public', Auth(self.registration.creator)) admin_contributors = [] for contributor in self.registration.contributors: if Contributor.objects.get(user_id=contributor.id, node_id=self.registration.id).permission == permissions.ADMIN: admin_contributors.append(contributor) - for admin in admin_contributors: - assert any([each[1]['to_addr'] == admin.username for each in self.mock_send_grid.call_args_list]) + + assert all([each['kwargs']['user'] in admin_contributors for each in notifications['emits']]) @mock.patch('osf.models.sanctions.EmailApprovableSanction.ask') def test_make_child_embargoed_registration_public_asks_all_admins_in_tree(self, mock_ask): diff --git a/tests/test_registrations/test_retractions.py b/tests/test_registrations/test_retractions.py index 22ee51827dd..cb461d0146b 100644 --- a/tests/test_registrations/test_retractions.py +++ b/tests/test_registrations/test_retractions.py @@ -22,10 +22,9 @@ InvalidSanctionApprovalToken, InvalidSanctionRejectionToken, NodeStateError, ) -from osf.models import Contributor, Retraction +from osf.models import Contributor, Retraction, NotificationType from osf.utils import permissions -from conftest import start_mock_send_grid - +from tests.utils import capture_notifications @pytest.mark.enable_bookmark_creation @@ -753,8 +752,6 @@ def test_POST_retraction_to_subproject_component_returns_HTTPError_BAD_REQUEST(s @pytest.mark.enable_bookmark_creation @pytest.mark.usefixtures('mock_gravy_valet_get_verified_links') -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) class RegistrationRetractionViewsTestCase(OsfTestCase): def setUp(self): super().setUp() @@ -767,8 +764,6 @@ def setUp(self): self.retraction_get_url = self.registration.web_url_for('node_registration_retraction_get') self.justification = fake.sentence() - self.mock_send_grid = start_mock_send_grid(self) - def test_GET_retraction_page_when_pending_retraction_returns_HTTPError_BAD_REQUEST(self): self.registration.retract_registration(self.user) self.registration.save() @@ -802,13 +797,14 @@ def test_POST_retraction_does_not_send_email_to_unregistered_admins(self): existing_user=unreg ) self.registration.save() - self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) - # Only the creator gets an email; the unreg user does not get emailed - assert self.mock_send_grid.call_count == 1 + with capture_notifications() as notifications: + self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_PENDING_RETRACTION_ADMIN def test_POST_pending_embargo_returns_HTTPError_HTTPOK(self): self.registration.embargo_registration( @@ -819,11 +815,12 @@ def test_POST_pending_embargo_returns_HTTPError_HTTPOK(self): self.registration.save() assert self.registration.is_pending_embargo - res = self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) + with capture_notifications(): + res = self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) assert res.status_code == http_status.HTTP_200_OK self.registration.reload() assert self.registration.is_pending_retraction @@ -840,12 +837,12 @@ def test_POST_active_embargo_returns_HTTPOK(self): approval_token = self.registration.embargo.approval_state[self.user._id]['approval_token'] self.registration.embargo.approve(user=self.user, token=approval_token) assert self.registration.embargo_end_date - - res = self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) + with capture_notifications(): + res = self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) assert res.status_code == http_status.HTTP_200_OK self.registration.reload() assert self.registration.is_pending_retraction @@ -857,11 +854,12 @@ def test_POST_retraction_by_non_admin_retract_HTTPError_UNAUTHORIZED(self): assert self.registration.retraction is None def test_POST_retraction_without_justification_returns_HTTPOK(self): - res = self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) + with capture_notifications(): + res = self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) assert res.status_code == http_status.HTTP_200_OK self.registration.reload() assert not self.registration.is_retracted @@ -870,35 +868,39 @@ def test_POST_retraction_without_justification_returns_HTTPOK(self): def test_valid_POST_retraction_adds_to_parent_projects_log(self): initial_project_logs = self.registration.registered_from.logs.count() - self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) + with capture_notifications(): + self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) self.registration.registered_from.reload() # Logs: Created, registered, retraction initiated assert self.registration.registered_from.logs.count() == initial_project_logs + 1 def test_valid_POST_retraction_when_pending_retraction_raises_400(self): - self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) - res = self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) + with capture_notifications(): + self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) + res = self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) assert res.status_code == 400 def test_valid_POST_calls_send_mail_with_username(self): - self.app.post( - self.retraction_post_url, - json={'justification': ''}, - auth=self.user.auth, - ) - assert self.mock_send_grid.called + with capture_notifications() as notifications: + self.app.post( + self.retraction_post_url, + json={'justification': ''}, + auth=self.user.auth, + ) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.NODE_PENDING_RETRACTION_ADMIN def test_non_contributor_GET_approval_returns_HTTPError_FORBIDDEN(self): non_contributor = AuthUserFactory() diff --git a/tests/test_registrations/test_review_flows.py b/tests/test_registrations/test_review_flows.py index a8653d32a0f..85323fcfe6b 100644 --- a/tests/test_registrations/test_review_flows.py +++ b/tests/test_registrations/test_review_flows.py @@ -1,10 +1,10 @@ -from unittest import mock - import pytest +import pytest_socket from api.providers.workflows import Workflows from framework.exceptions import PermissionsError from osf.migrations import update_provider_auth_groups +from osf.models import Retraction from osf_tests.factories import ( AuthUserFactory, EmbargoFactory, @@ -23,6 +23,8 @@ from tests.base import OsfTestCase from transitions import MachineError +from tests.utils import capture_notifications + DUMMY_TOKEN = tokens.encode({ 'dummy': 'token' }) @@ -96,7 +98,11 @@ def test_approval_flow(self, sanction_object, initial_state, end_state): assert registration.sanction._id == sanction_object._id approval_token = sanction_object.token_for_user(registration.creator, 'approval') - sanction_object.approve(user=registration.creator, token=approval_token) + try: + sanction_object.approve(user=registration.creator, token=approval_token) + except pytest_socket.SocketConnectBlockedError: + with capture_notifications(): + sanction_object.approve(user=registration.creator, token=approval_token) registration.refresh_from_db() assert registration.moderation_state == end_state.db_name @@ -131,13 +137,16 @@ def test_rejection_flow(self, sanction_object, initial_state, end_state): assert registration.sanction._id == sanction_object._id rejection_token = sanction_object.token_for_user(registration.creator, 'rejection') - sanction_object.reject(user=registration.creator, token=rejection_token) + try: + sanction_object.reject(user=registration.creator, token=rejection_token) + except pytest_socket.SocketConnectBlockedError: + with capture_notifications(): + sanction_object.reject(user=registration.creator, token=rejection_token) assert sum([val['has_rejected'] for val in sanction_object.approval_state.values()]) == 1 registration.refresh_from_db() assert registration.moderation_state == end_state.db_name - @pytest.mark.parametrize('sanction_object', [registration_approval, embargo, retraction]) def test_approve_after_reject_fails(self, sanction_object): sanction_object = sanction_object() @@ -153,7 +162,7 @@ def test_approve_after_reject_fails(self, sanction_object): sanction_object.approve(user=registration.creator, token=approval_token) @pytest.mark.parametrize('sanction_object', [registration_approval, embargo, retraction]) - def test_reject_after_arpprove_fails(self, sanction_object): + def test_reject_after_approve_fails(self, sanction_object): # using fixtures in parametrize returns the function sanction_object = sanction_object() sanction_object.to_APPROVED() @@ -251,11 +260,18 @@ def test_approval_flow( assert registration.sanction._id == sanction_object._id approval_token = sanction_object.token_for_user(registration.creator, 'approval') - sanction_object.approve(user=registration.creator, token=approval_token) + + with capture_notifications(): + sanction_object.approve(user=registration.creator, token=approval_token) registration.refresh_from_db() assert registration.moderation_state == intermediate_state.db_name - sanction_object.accept(user=moderator) + try: + with capture_notifications(): + sanction_object.accept(user=moderator) + except AssertionError: + sanction_object.accept(user=moderator) + registration.refresh_from_db() assert registration.moderation_state == end_state.db_name @@ -326,11 +342,17 @@ def test_moderator_rejection_flow( assert registration.sanction._id == sanction_object._id approval_token = sanction_object.token_for_user(registration.creator, 'approval') - sanction_object.approve(user=registration.creator, token=approval_token) + with capture_notifications(): + sanction_object.approve(user=registration.creator, token=approval_token) registration.refresh_from_db() assert registration.moderation_state == intermediate_state.db_name - sanction_object.reject(user=moderator) + try: + with capture_notifications(): + sanction_object.reject(user=moderator) + except AssertionError: + sanction_object.reject(user=moderator) + registration.refresh_from_db() assert registration.moderation_state == end_state.db_name @@ -338,7 +360,8 @@ def test_moderator_rejection_flow( def test_admin_cannot_give_moderator_approval(self, sanction_object, provider): # using fixtures in parametrize returns the function sanction_object = sanction_object(provider) - sanction_object.to_PENDING_MODERATION() + with capture_notifications(): + sanction_object.to_PENDING_MODERATION() registration = sanction_object.target_registration approval_token = sanction_object.token_for_user(registration.creator, 'approval') @@ -352,7 +375,8 @@ def test_admin_cannot_give_moderator_approval(self, sanction_object, provider): def test_admin_cannot_reject_after_admin_approval_granted(self, sanction_object, provider): # using fixtures in parametrize returns the function sanction_object = sanction_object(provider) - sanction_object.to_PENDING_MODERATION() + with capture_notifications(): + sanction_object.to_PENDING_MODERATION() registration = sanction_object.target_registration rejection_token = sanction_object.token_for_user(registration.creator, 'rejection') @@ -512,25 +536,40 @@ def test_moderator_approve_after_rejected_raises_machine_error( with pytest.raises(MachineError): sanction_object.accept(user=moderator) - @pytest.mark.parametrize('sanction_object', [registration_approval, embargo, retraction]) def test_provider_admin_can_accept_as_moderator( self, sanction_object, provider, provider_admin): sanction_object = sanction_object(provider) - sanction_object.accept() + try: + with capture_notifications(): + sanction_object.accept() + except AssertionError: + sanction_object.accept() + assert sanction_object.approval_stage is ApprovalStates.PENDING_MODERATION - sanction_object.accept(user=provider_admin) + try: + with capture_notifications(): + sanction_object.accept(user=provider_admin) + except AssertionError: + sanction_object.accept(user=provider_admin) assert sanction_object.approval_stage is ApprovalStates.APPROVED @pytest.mark.parametrize('sanction_object', [registration_approval, embargo, retraction]) def test_provider_admin_can_reject_as_moderator( self, sanction_object, provider, provider_admin): sanction_object = sanction_object(provider) - sanction_object.accept() + if sanction_object in (embargo, registration_approval): + sanction_object.accept() + else: + with capture_notifications(): + sanction_object.accept() assert sanction_object.approval_stage is ApprovalStates.PENDING_MODERATION - - sanction_object.reject(user=provider_admin) + if isinstance(sanction_object, Retraction): + with capture_notifications(): + sanction_object.reject(user=provider_admin) + else: + sanction_object.reject(user=provider_admin) assert sanction_object.approval_stage is ApprovalStates.MODERATOR_REJECTED @pytest.mark.enable_bookmark_creation @@ -563,7 +602,8 @@ def setUp(self): def test_embargo_termination_approved_by_admin(self): assert self.registration.moderation_state == RegistrationModerationStates.EMBARGO.db_name - embargo_termination = self.registration.request_embargo_termination(self.user) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) pending_embargo_termination_state = RegistrationModerationStates.PENDING_EMBARGO_TERMINATION assert self.registration.moderation_state == pending_embargo_termination_state.db_name @@ -576,7 +616,8 @@ def test_embargo_termination_approved_by_admin(self): def test_embargo_termination_rejected_by_admin(self): assert self.registration.moderation_state == RegistrationModerationStates.EMBARGO.db_name - embargo_termination = self.registration.request_embargo_termination(self.user) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) assert self.registration.moderation_state == RegistrationModerationStates.PENDING_EMBARGO_TERMINATION.db_name rejection_token = embargo_termination.token_for_user(self.user, 'rejection') @@ -585,7 +626,8 @@ def test_embargo_termination_rejected_by_admin(self): assert self.registration.moderation_state == RegistrationModerationStates.EMBARGO.db_name def test_embargo_termination_doesnt_require_moderator_approval(self): - embargo_termination = self.registration.request_embargo_termination(self.user) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) approval_token = embargo_termination.token_for_user(self.user, 'approval') embargo_termination.approve(user=self.user, token=approval_token) self.registration.refresh_from_db() @@ -593,17 +635,20 @@ def test_embargo_termination_doesnt_require_moderator_approval(self): assert self.embargo.approval_stage is ApprovalStates.COMPLETED def test_moderator_cannot_approve_embargo_termination(self): - embargo_termination = self.registration.request_embargo_termination(self.user) - with pytest.raises(PermissionsError): - embargo_termination.accept(user=self.moderator) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) + with pytest.raises(PermissionsError): + embargo_termination.accept(user=self.moderator) def test_moderator_cannot_reject_embargo_termination(self): - embargo_termination = self.registration.request_embargo_termination(self.user) - with pytest.raises(PermissionsError): - embargo_termination.reject(user=self.moderator) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) + with pytest.raises(PermissionsError): + embargo_termination.reject(user=self.moderator) def test_approve_after_approve_is_noop(self): - embargo_termination = self.registration.request_embargo_termination(self.user) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) approval_token = embargo_termination.token_for_user(self.user, 'approval') embargo_termination.approve(user=self.user, token=approval_token) @@ -614,7 +659,8 @@ def test_approve_after_approve_is_noop(self): assert self.registration.moderation_state == RegistrationModerationStates.ACCEPTED.db_name def test_reject_afer_reject_is_noop(self): - embargo_termination = self.registration.request_embargo_termination(self.user) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) rejection_token = embargo_termination.token_for_user(self.user, 'rejection') embargo_termination.reject(user=self.user, token=rejection_token) @@ -625,7 +671,8 @@ def test_reject_afer_reject_is_noop(self): assert self.registration.moderation_state == RegistrationModerationStates.EMBARGO.db_name def test_reject_after_accept_raises_machine_error(self): - embargo_termination = self.registration.request_embargo_termination(self.user) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) approval_token = embargo_termination.token_for_user(self.user, 'approval') embargo_termination.approve(user=self.user, token=approval_token) @@ -634,7 +681,8 @@ def test_reject_after_accept_raises_machine_error(self): embargo_termination.reject(user=self.user, token=rejection_token) def test_accept_after_reject_raises_machine_error(self): - embargo_termination = self.registration.request_embargo_termination(self.user) + with capture_notifications(): + embargo_termination = self.registration.request_embargo_termination(self.user) rejection_token = embargo_termination.token_for_user(self.user, 'rejection') embargo_termination.reject(user=self.user, token=rejection_token) @@ -676,7 +724,8 @@ def test_admin_accept_submission_writes_submit_action(self, sanction_object, pro registration = sanction_object.target_registration assert registration.actions.count() == 0 - sanction_object.accept() + with capture_notifications(): + sanction_object.accept() registration.refresh_from_db() latest_action = registration.actions.last() assert latest_action.trigger == RegistrationModerationTriggers.SUBMIT.db_name @@ -688,7 +737,8 @@ def test_moderator_accept_submission_writes_accept_submission_action( registration = sanction_object.target_registration assert registration.actions.count() == 0 - sanction_object.accept() + with capture_notifications(): + sanction_object.accept() sanction_object.accept(user=moderator) registration.refresh_from_db() latest_action = registration.actions.last() @@ -700,8 +750,9 @@ def test_moderator_reject_submission_writes_accept_submission_action(self, sanct registration = sanction_object.target_registration assert registration.actions.count() == 0 - sanction_object.accept() - sanction_object.reject(user=moderator) + with capture_notifications(): + sanction_object.accept() + sanction_object.reject(user=moderator) registration.refresh_from_db() latest_action = registration.actions.last() assert latest_action.trigger == RegistrationModerationTriggers.REJECT_SUBMISSION.db_name @@ -710,7 +761,8 @@ def test_admin_accept_retraction_writes_request_withdrawal_action(self, retracti registration = retraction_fixture.target_registration assert registration.actions.count() == 0 - retraction_fixture.accept() + with capture_notifications(): + retraction_fixture.accept() registration.refresh_from_db() latest_action = registration.actions.last() assert latest_action.trigger == RegistrationModerationTriggers.REQUEST_WITHDRAWAL.db_name @@ -721,8 +773,9 @@ def test_moderator_accept_retraction_writes_accept_withdrawal_action(self, retra registration = retraction_fixture.target_registration assert registration.actions.count() == 0 - retraction_fixture.accept() - retraction_fixture.accept(user=moderator) + with capture_notifications(): + retraction_fixture.accept() + retraction_fixture.accept(user=moderator) registration.refresh_from_db() latest_action = registration.actions.last() assert latest_action.trigger == RegistrationModerationTriggers.ACCEPT_WITHDRAWAL.db_name @@ -731,8 +784,9 @@ def test_moderator_reject_retraction_writes_reject_withdrawal_action(self, retra registration = retraction_fixture.target_registration assert registration.actions.count() == 0 - retraction_fixture.accept() - retraction_fixture.reject(user=moderator) + with capture_notifications(): + retraction_fixture.accept() + retraction_fixture.reject(user=moderator) registration.refresh_from_db() latest_action = registration.actions.last() assert latest_action.trigger == RegistrationModerationTriggers.REJECT_WITHDRAWAL.db_name @@ -792,7 +846,11 @@ def test_approval(self, pending_registration, child_registration, grandchild_reg assert child_registration.moderation_state == pending_registration.moderation_state assert grandchild_registration.moderation_state == pending_registration.moderation_state - pending_registration.sanction.accept() + try: + pending_registration.sanction.accept() + except pytest_socket.SocketConnectBlockedError: + with capture_notifications(): + pending_registration.sanction.accept() # verify that all registrations have updated state for reg in [pending_registration, child_registration, grandchild_registration]: diff --git a/tests/test_registrations/test_views.py b/tests/test_registrations/test_views.py index 034b7b31ae4..8739e3d4b6d 100644 --- a/tests/test_registrations/test_views.py +++ b/tests/test_registrations/test_views.py @@ -17,6 +17,7 @@ from osf.migrations import update_provider_auth_groups from osf.models import RegistrationSchema, DraftRegistration from osf.utils import permissions +from tests.utils import capture_notifications from website.project.metadata.schemas import _name_to_id from website.util import api_url_for from website.project.views import drafts as draft_views @@ -524,7 +525,8 @@ def test_moderator_can_view_subpath_of_submitted_registration( self, app, embargoed_registration, moderator, registration_subpath): # Moderators may need to see details of the pending registration # in order to determine whether to give approval - embargoed_registration.embargo.accept() + with capture_notifications(): + embargoed_registration.embargo.accept() embargoed_registration.refresh_from_db() assert embargoed_registration.moderation_state == 'pending' @@ -535,8 +537,9 @@ def test_moderator_can_viw_subpath_of_embargoed_registration( self, app, embargoed_registration, moderator, registration_subpath): # Moderators may need to see details of an embargoed registration # to determine if there is a need to withdraw before it becomes public - embargoed_registration.embargo.accept() - embargoed_registration.embargo.accept(user=moderator) + with capture_notifications(): + embargoed_registration.embargo.accept() + embargoed_registration.embargo.accept(user=moderator) embargoed_registration.refresh_from_db() assert embargoed_registration.moderation_state == 'embargo' diff --git a/tests/test_resend_confirmation.py b/tests/test_resend_confirmation.py new file mode 100644 index 00000000000..d5c766e81af --- /dev/null +++ b/tests/test_resend_confirmation.py @@ -0,0 +1,79 @@ +from osf.models import NotificationType +from tests.base import OsfTestCase +from osf_tests.factories import ( + UserFactory, + UnconfirmedUserFactory, +) +from tests.utils import capture_notifications +from website.util import web_url_for +from tests.test_webtests import assert_in_html + +class TestResendConfirmation(OsfTestCase): + + def setUp(self): + super().setUp() + self.unconfirmed_user = UnconfirmedUserFactory() + self.confirmed_user = UserFactory() + self.get_url = web_url_for('resend_confirmation_get') + self.post_url = web_url_for('resend_confirmation_post') + + # test that resend confirmation page is load correctly + def test_resend_confirmation_get(self): + res = self.app.get(self.get_url) + assert res.status_code == 200 + assert 'Resend Confirmation' in res.text + assert res.get_form('resendForm') + + # test that unconfirmed user can receive resend confirmation email + def test_can_receive_resend_confirmation_email(self): + # load resend confirmation page and submit email + res = self.app.get(self.get_url) + form = res.get_form('resendForm') + form['email'] = self.unconfirmed_user.unconfirmed_emails[0] + with capture_notifications() as notifications: + res = form.submit(self.app) + # check email, request and response + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_INITIAL_CONFIRM_EMAIL + assert res.status_code == 200 + assert res.request.path == self.post_url + assert_in_html('If there is an OSF account', res.text) + + + # test that confirmed user cannot receive resend confirmation email + def test_cannot_receive_resend_confirmation_email_1(self): + # load resend confirmation page and submit email + res = self.app.get(self.get_url) + form = res.get_form('resendForm') + form['email'] = self.confirmed_user.emails.first().address + res = form.submit(self.app) + assert res.status_code == 200 + assert res.request.path == self.post_url + assert_in_html('has already been confirmed', res.text) + + # test that non-existing user cannot receive resend confirmation email + def test_cannot_receive_resend_confirmation_email_2(self): + # load resend confirmation page and submit email + res = self.app.get(self.get_url) + form = res.get_form('resendForm') + form['email'] = 'random@random.com' + res = form.submit(self.app) + # check email, request and response + assert res.status_code == 200 + assert res.request.path == self.post_url + assert_in_html('If there is an OSF account', res.text) + + # test that user cannot submit resend confirmation request too quickly + def test_cannot_resend_confirmation_twice_quickly(self): + # load resend confirmation page and submit email + res = self.app.get(self.get_url) + form = res.get_form('resendForm') + form['email'] = self.unconfirmed_user.email + with capture_notifications(): + form.submit(self.app) + res = form.submit(self.app) + + # check request and response + assert res.status_code == 200 + assert_in_html('Please wait', res.text) + diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 08f92f7d232..686c7a4c8ea 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -14,6 +14,7 @@ from tests.base import OsfTestCase, get_default_metaschema from framework.auth import Auth +from tests.utils import capture_notifications from website.project.views.node import _view_project, _serialize_node_search, _get_children, _get_readable_descendants from website.views import serialize_node_summary from website.profile import utils @@ -146,7 +147,8 @@ def test_serialize_node_summary_private_fork_should_include_is_fork(self): # non-contributor cannot see private fork of public project node = ProjectFactory(is_public=True) consolidated_auth = Auth(user=node.creator) - fork = node.fork_node(consolidated_auth) + with capture_notifications(): + fork = node.fork_node(consolidated_auth) res = serialize_node_summary( fork, auth=Auth(user), @@ -163,7 +165,8 @@ def test_serialize_node_summary_private_fork_private_project_should_include_is_f # contributor cannot see private fork of this project consolidated_auth = Auth(user=node.creator) - fork = node.fork_node(consolidated_auth) + with capture_notifications(): + fork = node.fork_node(consolidated_auth) res = serialize_node_summary( fork, auth=Auth(user), @@ -248,7 +251,8 @@ def setUp(self): def test_view_project_embed_forks_excludes_registrations(self): project = ProjectFactory() - fork = project.fork_node(Auth(project.creator)) + with capture_notifications(): + fork = project.fork_node(Auth(project.creator)) reg = RegistrationFactory(project=fork) res = _view_project(project, auth=Auth(project.creator), embed_forks=True) diff --git a/tests/test_spam_mixin.py b/tests/test_spam_mixin.py index 0713d0b4c54..91dc6387181 100644 --- a/tests/test_spam_mixin.py +++ b/tests/test_spam_mixin.py @@ -10,22 +10,24 @@ from tests.base import DbTestCase from osf_tests.factories import UserFactory, CommentFactory, ProjectFactory, PreprintFactory, RegistrationFactory, AuthUserFactory -from osf.models import NotableDomain, SpamStatus -from website import settings, mails +from osf.models import NotableDomain, SpamStatus, NotificationType +from tests.utils import capture_notifications +from website import settings @pytest.mark.django_db -@pytest.mark.usefixtures('mock_send_grid') -def test_throttled_autoban(mock_send_grid): +def test_throttled_autoban(): settings.SPAM_THROTTLE_AUTOBAN = True user = AuthUserFactory() projects = [] - for _ in range(7): - proj = ProjectFactory(creator=user) - proj.flag_spam() - proj.save() - projects.append(proj) - mock_send_grid.assert_called() + with capture_notifications() as notifications: + for _ in range(7): + proj = ProjectFactory(creator=user) + proj.flag_spam() + proj.save() + projects.append(proj) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.USER_SPAM_BANNED user.reload() assert user.is_disabled for project in projects: diff --git a/tests/test_user_claiming.py b/tests/test_user_claiming.py new file mode 100644 index 00000000000..8174a5600b5 --- /dev/null +++ b/tests/test_user_claiming.py @@ -0,0 +1,267 @@ +from rest_framework import status +import unittest + +import pytest +from framework.auth import exceptions +from framework.auth.core import Auth +from tests.base import OsfTestCase +from tests.base import fake +from osf_tests.factories import ( + fake_email, + AuthUserFactory, + PreprintFactory, + ProjectFactory, + UserFactory, + UnconfirmedUserFactory, + UnregUserFactory, +) +from tests.test_webtests import assert_in_html +from website import language +from website.util import api_url_for + +@pytest.mark.enable_bookmark_creation +@pytest.mark.enable_implicit_clean +class TestClaiming(OsfTestCase): + + def setUp(self): + super().setUp() + self.referrer = AuthUserFactory() + self.project = ProjectFactory(creator=self.referrer, is_public=True) + + def test_correct_name_shows_in_contributor_list(self): + name1, email = fake.name(), fake_email() + UnregUserFactory(fullname=name1, email=email) + name2, email = fake.name(), fake_email() + # Added with different name + self.project.add_unregistered_contributor(fullname=name2, + email=email, auth=Auth(self.referrer)) + self.project.save() + + res = self.app.get(self.project.url, auth=self.referrer.auth) + # Correct name is shown + assert_in_html(name2, res.text) + assert name1 not in res.text + + def test_user_can_set_password_on_claim_page(self): + name, email = fake.name(), fake_email() + new_user = self.project.add_unregistered_contributor( + email=email, + fullname=name, + auth=Auth(self.referrer) + ) + self.project.save() + claim_url = new_user.get_claim_url(self.project._primary_key) + res = self.app.get(claim_url) + self.project.reload() + assert 'Set Password' in res.text + form = res.get_form('setPasswordForm') + #form['username'] = new_user.username #Removed as long as E-mail can't be updated. + form['password'] = 'killerqueen' + form['password2'] = 'killerqueen' + self.app.resolve_redirect(form.submit(self.app)) + new_user.reload() + assert new_user.check_password('killerqueen') + + def test_sees_is_redirected_if_user_already_logged_in(self): + name, email = fake.name(), fake_email() + new_user = self.project.add_unregistered_contributor( + email=email, + fullname=name, + auth=Auth(self.referrer) + ) + self.project.save() + existing = AuthUserFactory() + claim_url = new_user.get_claim_url(self.project._primary_key) + # a user is already logged in + res = self.app.get(claim_url, auth=existing.auth) + assert res.status_code == 302 + + def test_unregistered_users_names_are_project_specific(self): + name1, name2, email = fake.name(), fake.name(), fake_email() + project2 = ProjectFactory(creator=self.referrer) + # different projects use different names for the same unreg contributor + self.project.add_unregistered_contributor( + email=email, + fullname=name1, + auth=Auth(self.referrer) + ) + self.project.save() + project2.add_unregistered_contributor( + email=email, + fullname=name2, + auth=Auth(self.referrer) + ) + project2.save() + # Each project displays a different name in the contributor list + res = self.app.get(self.project.url, auth=self.referrer.auth) + assert_in_html(name1, res.text) + + res2 = self.app.get(project2.url, auth=self.referrer.auth) + assert_in_html(name2, res2.text) + + @unittest.skip('as long as E-mails cannot be changed') + def test_cannot_set_email_to_a_user_that_already_exists(self): + reg_user = UserFactory() + name, email = fake.name(), fake_email() + new_user = self.project.add_unregistered_contributor( + email=email, + fullname=name, + auth=Auth(self.referrer) + ) + self.project.save() + # Goes to claim url and successfully claims account + claim_url = new_user.get_claim_url(self.project._primary_key) + res = self.app.get(claim_url) + self.project.reload() + assert 'Set Password' in res + form = res.get_form('setPasswordForm') + # Fills out an email that is the username of another user + form['username'] = reg_user.username + form['password'] = 'killerqueen' + form['password2'] = 'killerqueen' + res = form.submit(follow_redirects=True) + assert language.ALREADY_REGISTERED.format(email=reg_user.username) in res.text + + def test_correct_display_name_is_shown_at_claim_page(self): + original_name = fake.name() + unreg = UnregUserFactory(fullname=original_name) + + different_name = fake.name() + new_user = self.project.add_unregistered_contributor( + email=unreg.username, + fullname=different_name, + auth=Auth(self.referrer), + ) + self.project.save() + claim_url = new_user.get_claim_url(self.project._primary_key) + res = self.app.get(claim_url) + # Correct name (different_name) should be on page + assert_in_html(different_name, res.text) + + +class TestConfirmingEmail(OsfTestCase): + + def setUp(self): + super().setUp() + self.user = UnconfirmedUserFactory() + self.confirmation_url = self.user.get_confirmation_url( + self.user.username, + external=False, + ) + self.confirmation_token = self.user.get_confirmation_token( + self.user.username + ) + + def test_cannot_remove_another_user_email(self): + user1 = AuthUserFactory() + user2 = AuthUserFactory() + url = api_url_for('update_user') + header = {'id': user1.username, 'emails': [{'address': user1.username}]} + res = self.app.put(url, json=header, auth=user2.auth) + assert res.status_code == 403 + + def test_cannnot_make_primary_email_for_another_user(self): + user1 = AuthUserFactory() + user2 = AuthUserFactory() + email = 'test@cos.io' + user1.emails.create(address=email) + user1.save() + url = api_url_for('update_user') + header = {'id': user1.username, + 'emails': [{'address': user1.username, 'primary': False, 'confirmed': True}, + {'address': email, 'primary': True, 'confirmed': True} + ]} + res = self.app.put(url, json=header, auth=user2.auth) + assert res.status_code == 403 + + def test_cannnot_add_email_for_another_user(self): + user1 = AuthUserFactory() + user2 = AuthUserFactory() + email = 'test@cos.io' + url = api_url_for('update_user') + header = {'id': user1.username, + 'emails': [{'address': user1.username, 'primary': True, 'confirmed': True}, + {'address': email, 'primary': False, 'confirmed': False} + ]} + res = self.app.put(url, json=header, auth=user2.auth) + assert res.status_code == 403 + + def test_error_page_if_confirm_link_is_used(self): + self.user.confirm_email(self.confirmation_token) + self.user.save() + res = self.app.get(self.confirmation_url) + + assert exceptions.InvalidTokenError.message_short in res.text + assert res.status_code == status.HTTP_400_BAD_REQUEST + + +@pytest.mark.enable_implicit_clean +@pytest.mark.enable_bookmark_creation +class TestClaimingAsARegisteredUser(OsfTestCase): + + def setUp(self): + super().setUp() + self.referrer = AuthUserFactory() + self.project = ProjectFactory(creator=self.referrer, is_public=True) + name, email = fake.name(), fake_email() + self.user = self.project.add_unregistered_contributor( + fullname=name, + email=email, + auth=Auth(user=self.referrer) + ) + self.project.save() + + def test_claim_user_registered_with_correct_password(self): + reg_user = AuthUserFactory() # NOTE: AuthUserFactory sets password as 'queenfan86' + url = self.user.get_claim_url(self.project._primary_key) + # Follow to password re-enter page + res = self.app.get(url, auth=reg_user.auth, follow_redirects=True) + + # verify that the "Claim Account" form is returned + assert 'Claim Contributor' in res.text + + form = res.get_form('claimContributorForm') + form['password'] = 'queenfan86' + res = form.submit(self.app, auth=reg_user.auth) + self.app.resolve_redirect(res) + self.project.reload() + self.user.reload() + # user is now a contributor to the project + assert reg_user in self.project.contributors + + # the unregistered user (self.user) is removed as a contributor, and their + assert self.user not in self.project.contributors + + # unclaimed record for the project has been deleted + assert self.project not in self.user.unclaimed_records + + def test_claim_user_registered_preprint_with_correct_password(self): + preprint = PreprintFactory(creator=self.referrer) + name, email = fake.name(), fake_email() + unreg_user = preprint.add_unregistered_contributor( + fullname=name, + email=email, + auth=Auth(user=self.referrer) + ) + reg_user = AuthUserFactory() # NOTE: AuthUserFactory sets password as 'queenfan86' + url = unreg_user.get_claim_url(preprint._id) + # Follow to password re-enter page + res = self.app.get(url, auth=reg_user.auth, follow_redirects=True) + + # verify that the "Claim Account" form is returned + assert 'Claim Contributor' in res.text + + form = res.get_form('claimContributorForm') + form['password'] = 'queenfan86' + res = form.submit(self.app, auth=reg_user.auth) + + preprint.reload() + unreg_user.reload() + # user is now a contributor to the project + assert reg_user in preprint.contributors + + # the unregistered user (unreg_user) is removed as a contributor, and their + assert unreg_user not in preprint.contributors + + # unclaimed record for the project has been deleted + assert preprint not in unreg_user.unclaimed_records diff --git a/tests/test_user_profile_view.py b/tests/test_user_profile_view.py index 876acba9bf9..e7e1a670506 100644 --- a/tests/test_user_profile_view.py +++ b/tests/test_user_profile_view.py @@ -2,16 +2,14 @@ """Views tests for the OSF.""" from hashlib import md5 from unittest import mock + import pytest from rest_framework import status as http_status from addons.github.tests.factories import GitHubAccountFactory -from conftest import start_mock_send_grid from framework.celery_tasks import handlers from osf.external.spam import tasks as spam_tasks -from osf.models import ( - NotableDomain -) +from osf.models import NotableDomain, NotificationType from osf_tests.factories import ( fake_email, ApiOAuth2ApplicationFactory, @@ -23,6 +21,7 @@ fake, OsfTestCase, ) +from tests.utils import capture_notifications from website import mailchimp_utils from website.settings import MAILCHIMP_GENERAL_LIST from website.util import api_url_for, web_url_for @@ -349,7 +348,8 @@ def test_add_emails_return_emails(self): 'emails': [{'address': user1.username, 'primary': True, 'confirmed': True}, {'address': email, 'primary': False, 'confirmed': False} ]} - res = self.app.put(url, json=header, auth=user1.auth) + with capture_notifications(): + res = self.app.put(url, json=header, auth=user1.auth) assert res.status_code == 200 assert 'emails' in res.json['profile'] assert len(res.json['profile']['emails']) == 2 @@ -361,7 +361,8 @@ def test_resend_confirmation_return_emails(self): header = {'id': user1._id, 'email': {'address': email, 'primary': False, 'confirmed': False} } - res = self.app.put(url, json=header, auth=user1.auth) + with capture_notifications(): + res = self.app.put(url, json=header, auth=user1.auth) assert res.status_code == 200 assert 'emails' in res.json['profile'] assert len(res.json['profile']['emails']) == 2 @@ -386,7 +387,8 @@ def test_update_user_mailing_lists(self, mock_get_mailchimp_api): {'address': self.user.username, 'primary': False, 'confirmed': True}, {'address': email, 'primary': True, 'confirmed': True}] payload = {'locale': '', 'id': self.user._id, 'emails': emails} - self.app.put(url, json=payload, auth=self.user.auth) + with capture_notifications(): + self.app.put(url, json=payload, auth=self.user.auth) # the test app doesn't have celery handlers attached, so we need to call this manually. handlers.celery_teardown_request() @@ -427,7 +429,8 @@ def test_unsubscribe_mailchimp_not_called_if_user_not_subscribed(self, mock_get_ {'address': self.user.username, 'primary': False, 'confirmed': True}, {'address': email, 'primary': True, 'confirmed': True}] payload = {'locale': '', 'id': self.user._id, 'emails': emails} - self.app.put(url, json=payload, auth=self.user.auth) + with capture_notifications(): + self.app.put(url, json=payload, auth=self.user.auth) assert mock_client.lists.members.delete.call_count == 0 assert mock_client.lists.members.create_or_update.call_count == 0 @@ -509,12 +512,11 @@ class TestUserAccount(OsfTestCase): def setUp(self): super().setUp() self.user = AuthUserFactory() - self.user.set_password('password') + with capture_notifications(): + self.user.set_password('password') self.user.auth = (self.user.username, 'password') self.user.save() - self.mock_send_grid = start_mock_send_grid(self) - def test_password_change_valid(self, old_password='password', new_password='Pa$$w0rd', @@ -525,7 +527,8 @@ def test_password_change_valid(self, 'new_password': new_password, 'confirm_password': confirm_password, } - res = self.app.post(url, data=post_data, auth=(self.user.username, old_password)) + with capture_notifications(): + res = self.app.post(url, data=post_data, auth=(self.user.username, old_password)) assert res.status_code == 302 res = self.app.post(url, data=post_data, auth=(self.user.username, new_password), follow_redirects=True) assert res.status_code == 200 @@ -663,7 +666,8 @@ def test_old_password_invalid_attempts_reset_if_password_successfully_reset(self assert res.status_code == 200 # Make a second request that successfully changes password - res = self.app.post(url, data=correct_post_data, auth=self.user.auth) + with capture_notifications(): + res = self.app.post(url, data=correct_post_data, auth=self.user.auth) self.user.reload() assert self.user.change_password_last_attempt is not None assert self.user.old_password_invalid_attempts == 0 @@ -719,15 +723,15 @@ def test_password_change_invalid_empty_string_confirm_password(self): def test_password_change_invalid_blank_confirm_password(self): self.test_password_change_invalid_blank_password('password', 'new password', ' ') - @mock.patch('website.mails.settings.USE_EMAIL', True) - @mock.patch('website.mails.settings.USE_CELERY', False) def test_user_cannot_request_account_export_before_throttle_expires(self): url = api_url_for('request_export') - self.app.post(url, auth=self.user.auth) - assert self.mock_send_grid.called + with capture_notifications() as notifications: + self.app.post(url, auth=self.user.auth) + assert len(notifications['emits']) == 1 + assert notifications['emits'][0]['type'] == NotificationType.Type.DESK_REQUEST_EXPORT + res = self.app.post(url, auth=self.user.auth) assert res.status_code == 400 - assert self.mock_send_grid.call_count == 1 def test_get_unconfirmed_emails_exclude_external_identity(self): external_identity = { diff --git a/tests/test_webtests.py b/tests/test_webtests.py index ae1a30e7618..7004b0fe9ea 100644 --- a/tests/test_webtests.py +++ b/tests/test_webtests.py @@ -1,8 +1,5 @@ #!/usr/bin/env python3 """Functional tests using WebTest.""" -from urllib.parse import quote_plus - -from rest_framework import status import logging import unittest @@ -13,30 +10,23 @@ from bs4 import BeautifulSoup from django.utils import timezone from addons.wiki.utils import to_mongo_key -from framework.auth import exceptions from framework.auth.core import Auth from tests.base import OsfTestCase -from tests.base import fake from osf_tests.factories import ( - fake_email, AuthUserFactory, NodeFactory, PreprintFactory, PreprintProviderFactory, PrivateLinkFactory, ProjectFactory, - RegistrationFactory, SubjectFactory, UserFactory, - UnconfirmedUserFactory, - UnregUserFactory, ) from osf.utils import permissions from addons.wiki.models import WikiPage, WikiVersion from addons.wiki.tests.factories import WikiFactory, WikiVersionFactory -from website import language -from website.util import web_url_for, api_url_for -from conftest import start_mock_send_grid +from tests.utils import capture_notifications +from website.util import web_url_for logging.getLogger('website.project.model').setLevel(logging.ERROR) @@ -58,7 +48,8 @@ class TestDisabledUser(OsfTestCase): def setUp(self): super().setUp() self.user = UserFactory() - self.user.set_password('Korben Dallas') + with capture_notifications(): + self.user.set_password('Korben Dallas') self.user.is_disabled = True self.user.save() @@ -205,7 +196,7 @@ def test_wiki_content(self): user=self.user, node=project, ) - wiki = WikiVersionFactory( + WikiVersionFactory( wiki_page=wiki_page, content=wiki_content ) @@ -265,7 +256,8 @@ def setUp(self): parent=self.project, ) self.component.save() - self.component.set_privacy('public', self.consolidate_auth) + with capture_notifications(): + self.component.set_privacy('public', self.consolidate_auth) self.component.set_privacy('private', self.consolidate_auth) self.project.save() self.project_url = self.project.web_url_for('view_project') @@ -382,10 +374,12 @@ def setUp(self): super().setUp() self.user = UserFactory.build() self.user.fullname = "tess' test string" - self.user.set_password('science') + with capture_notifications(): + self.user.set_password('science') self.user.save() self.dupe = UserFactory.build() - self.dupe.set_password('example') + with capture_notifications(): + self.dupe.set_password('example') self.dupe.save() def test_merged_user_is_not_shown_as_a_contributor(self): @@ -430,7 +424,8 @@ def setUp(self): self.component = NodeFactory(parent=self.project, category='hypothesis', creator=self.user) # Hack: Add some logs to component; should be unnecessary pending # improvements to factories from @rliebz - self.component.set_privacy('public', auth=self.consolidate_auth) + with capture_notifications(): + self.component.set_privacy('public', auth=self.consolidate_auth) self.component.set_privacy('private', auth=self.consolidate_auth) self.wiki = WikiFactory( user=self.user, @@ -467,631 +462,6 @@ def test_wiki_url(self): assert self._url_to_body(self.wiki.deep_url) == self._url_to_body(self.wiki.url) -@pytest.mark.enable_bookmark_creation -@pytest.mark.enable_implicit_clean -class TestClaiming(OsfTestCase): - - def setUp(self): - super().setUp() - self.referrer = AuthUserFactory() - self.project = ProjectFactory(creator=self.referrer, is_public=True) - - def test_correct_name_shows_in_contributor_list(self): - name1, email = fake.name(), fake_email() - UnregUserFactory(fullname=name1, email=email) - name2, email = fake.name(), fake_email() - # Added with different name - self.project.add_unregistered_contributor(fullname=name2, - email=email, auth=Auth(self.referrer)) - self.project.save() - - res = self.app.get(self.project.url, auth=self.referrer.auth) - # Correct name is shown - assert_in_html(name2, res.text) - assert name1 not in res.text - - def test_user_can_set_password_on_claim_page(self): - name, email = fake.name(), fake_email() - new_user = self.project.add_unregistered_contributor( - email=email, - fullname=name, - auth=Auth(self.referrer) - ) - self.project.save() - claim_url = new_user.get_claim_url(self.project._primary_key) - res = self.app.get(claim_url) - self.project.reload() - assert 'Set Password' in res.text - form = res.get_form('setPasswordForm') - #form['username'] = new_user.username #Removed as long as E-mail can't be updated. - form['password'] = 'killerqueen' - form['password2'] = 'killerqueen' - self.app.resolve_redirect(form.submit(self.app)) - new_user.reload() - assert new_user.check_password('killerqueen') - - def test_sees_is_redirected_if_user_already_logged_in(self): - name, email = fake.name(), fake_email() - new_user = self.project.add_unregistered_contributor( - email=email, - fullname=name, - auth=Auth(self.referrer) - ) - self.project.save() - existing = AuthUserFactory() - claim_url = new_user.get_claim_url(self.project._primary_key) - # a user is already logged in - res = self.app.get(claim_url, auth=existing.auth) - assert res.status_code == 302 - - def test_unregistered_users_names_are_project_specific(self): - name1, name2, email = fake.name(), fake.name(), fake_email() - project2 = ProjectFactory(creator=self.referrer) - # different projects use different names for the same unreg contributor - self.project.add_unregistered_contributor( - email=email, - fullname=name1, - auth=Auth(self.referrer) - ) - self.project.save() - project2.add_unregistered_contributor( - email=email, - fullname=name2, - auth=Auth(self.referrer) - ) - project2.save() - # Each project displays a different name in the contributor list - res = self.app.get(self.project.url, auth=self.referrer.auth) - assert_in_html(name1, res.text) - - res2 = self.app.get(project2.url, auth=self.referrer.auth) - assert_in_html(name2, res2.text) - - @unittest.skip('as long as E-mails cannot be changed') - def test_cannot_set_email_to_a_user_that_already_exists(self): - reg_user = UserFactory() - name, email = fake.name(), fake_email() - new_user = self.project.add_unregistered_contributor( - email=email, - fullname=name, - auth=Auth(self.referrer) - ) - self.project.save() - # Goes to claim url and successfully claims account - claim_url = new_user.get_claim_url(self.project._primary_key) - res = self.app.get(claim_url) - self.project.reload() - assert 'Set Password' in res - form = res.get_form('setPasswordForm') - # Fills out an email that is the username of another user - form['username'] = reg_user.username - form['password'] = 'killerqueen' - form['password2'] = 'killerqueen' - res = form.submit(follow_redirects=True) - assert language.ALREADY_REGISTERED.format(email=reg_user.username) in res.text - - def test_correct_display_name_is_shown_at_claim_page(self): - original_name = fake.name() - unreg = UnregUserFactory(fullname=original_name) - - different_name = fake.name() - new_user = self.project.add_unregistered_contributor( - email=unreg.username, - fullname=different_name, - auth=Auth(self.referrer), - ) - self.project.save() - claim_url = new_user.get_claim_url(self.project._primary_key) - res = self.app.get(claim_url) - # Correct name (different_name) should be on page - assert_in_html(different_name, res.text) - - -class TestConfirmingEmail(OsfTestCase): - - def setUp(self): - super().setUp() - self.user = UnconfirmedUserFactory() - self.confirmation_url = self.user.get_confirmation_url( - self.user.username, - external=False, - ) - self.confirmation_token = self.user.get_confirmation_token( - self.user.username - ) - - def test_cannot_remove_another_user_email(self): - user1 = AuthUserFactory() - user2 = AuthUserFactory() - url = api_url_for('update_user') - header = {'id': user1.username, 'emails': [{'address': user1.username}]} - res = self.app.put(url, json=header, auth=user2.auth) - assert res.status_code == 403 - - def test_cannnot_make_primary_email_for_another_user(self): - user1 = AuthUserFactory() - user2 = AuthUserFactory() - email = 'test@cos.io' - user1.emails.create(address=email) - user1.save() - url = api_url_for('update_user') - header = {'id': user1.username, - 'emails': [{'address': user1.username, 'primary': False, 'confirmed': True}, - {'address': email, 'primary': True, 'confirmed': True} - ]} - res = self.app.put(url, json=header, auth=user2.auth) - assert res.status_code == 403 - - def test_cannnot_add_email_for_another_user(self): - user1 = AuthUserFactory() - user2 = AuthUserFactory() - email = 'test@cos.io' - url = api_url_for('update_user') - header = {'id': user1.username, - 'emails': [{'address': user1.username, 'primary': True, 'confirmed': True}, - {'address': email, 'primary': False, 'confirmed': False} - ]} - res = self.app.put(url, json=header, auth=user2.auth) - assert res.status_code == 403 - - def test_error_page_if_confirm_link_is_used(self): - self.user.confirm_email(self.confirmation_token) - self.user.save() - res = self.app.get(self.confirmation_url) - - assert exceptions.InvalidTokenError.message_short in res.text - assert res.status_code == status.HTTP_400_BAD_REQUEST - - -@pytest.mark.enable_implicit_clean -@pytest.mark.enable_bookmark_creation -class TestClaimingAsARegisteredUser(OsfTestCase): - - def setUp(self): - super().setUp() - self.referrer = AuthUserFactory() - self.project = ProjectFactory(creator=self.referrer, is_public=True) - name, email = fake.name(), fake_email() - self.user = self.project.add_unregistered_contributor( - fullname=name, - email=email, - auth=Auth(user=self.referrer) - ) - self.project.save() - - def test_claim_user_registered_with_correct_password(self): - reg_user = AuthUserFactory() # NOTE: AuthUserFactory sets password as 'queenfan86' - url = self.user.get_claim_url(self.project._primary_key) - # Follow to password re-enter page - res = self.app.get(url, auth=reg_user.auth, follow_redirects=True) - - # verify that the "Claim Account" form is returned - assert 'Claim Contributor' in res.text - - form = res.get_form('claimContributorForm') - form['password'] = 'queenfan86' - res = form.submit(self.app, auth=reg_user.auth) - self.app.resolve_redirect(res) - self.project.reload() - self.user.reload() - # user is now a contributor to the project - assert reg_user in self.project.contributors - - # the unregistered user (self.user) is removed as a contributor, and their - assert self.user not in self.project.contributors - - # unclaimed record for the project has been deleted - assert self.project not in self.user.unclaimed_records - - def test_claim_user_registered_preprint_with_correct_password(self): - preprint = PreprintFactory(creator=self.referrer) - name, email = fake.name(), fake_email() - unreg_user = preprint.add_unregistered_contributor( - fullname=name, - email=email, - auth=Auth(user=self.referrer) - ) - reg_user = AuthUserFactory() # NOTE: AuthUserFactory sets password as 'queenfan86' - url = unreg_user.get_claim_url(preprint._id) - # Follow to password re-enter page - res = self.app.get(url, auth=reg_user.auth, follow_redirects=True) - - # verify that the "Claim Account" form is returned - assert 'Claim Contributor' in res.text - - form = res.get_form('claimContributorForm') - form['password'] = 'queenfan86' - res = form.submit(self.app, auth=reg_user.auth) - - preprint.reload() - unreg_user.reload() - # user is now a contributor to the project - assert reg_user in preprint.contributors - - # the unregistered user (unreg_user) is removed as a contributor, and their - assert unreg_user not in preprint.contributors - - # unclaimed record for the project has been deleted - assert preprint not in unreg_user.unclaimed_records - - -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) -class TestResendConfirmation(OsfTestCase): - - def setUp(self): - super().setUp() - self.unconfirmed_user = UnconfirmedUserFactory() - self.confirmed_user = UserFactory() - self.get_url = web_url_for('resend_confirmation_get') - self.post_url = web_url_for('resend_confirmation_post') - - self.mock_send_grid = start_mock_send_grid(self) - - # test that resend confirmation page is load correctly - def test_resend_confirmation_get(self): - res = self.app.get(self.get_url) - assert res.status_code == 200 - assert 'Resend Confirmation' in res.text - assert res.get_form('resendForm') - - # test that unconfirmed user can receive resend confirmation email - def test_can_receive_resend_confirmation_email(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = self.unconfirmed_user.unconfirmed_emails[0] - res = form.submit(self.app) - - # check email, request and response - assert self.mock_send_grid.called - assert res.status_code == 200 - assert res.request.path == self.post_url - assert_in_html('If there is an OSF account', res.text) - - # test that confirmed user cannot receive resend confirmation email - def test_cannot_receive_resend_confirmation_email_1(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = self.confirmed_user.emails.first().address - res = form.submit(self.app) - - # check email, request and response - assert not self.mock_send_grid.called - assert res.status_code == 200 - assert res.request.path == self.post_url - assert_in_html('has already been confirmed', res.text) - - # test that non-existing user cannot receive resend confirmation email - def test_cannot_receive_resend_confirmation_email_2(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = 'random@random.com' - res = form.submit(self.app) - - # check email, request and response - assert not self.mock_send_grid.called - assert res.status_code == 200 - assert res.request.path == self.post_url - assert_in_html('If there is an OSF account', res.text) - - # test that user cannot submit resend confirmation request too quickly - def test_cannot_resend_confirmation_twice_quickly(self): - # load resend confirmation page and submit email - res = self.app.get(self.get_url) - form = res.get_form('resendForm') - form['email'] = self.unconfirmed_user.email - res = form.submit(self.app) - res = form.submit(self.app) - - # check request and response - assert res.status_code == 200 - assert_in_html('Please wait', res.text) - - -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) -class TestForgotPassword(OsfTestCase): - - def setUp(self): - super().setUp() - self.user = UserFactory() - self.auth_user = AuthUserFactory() - self.get_url = web_url_for('forgot_password_get') - self.post_url = web_url_for('forgot_password_post') - self.user.verification_key_v2 = {} - self.user.save() - - self.mock_send_grid = start_mock_send_grid(self) - - # log users out before they land on forgot password page - def test_forgot_password_logs_out_user(self): - # visit forgot password link while another user is logged in - res = self.app.get(self.get_url, auth=self.auth_user.auth) - # check redirection to CAS logout - assert res.status_code == 302 - location = res.headers.get('Location') - assert 'reauth' not in location - assert 'logout?service=' in location - assert 'forgotpassword' in location - - # test that forgot password page is loaded correctly - def test_get_forgot_password(self): - res = self.app.get(self.get_url) - assert res.status_code == 200 - assert 'Forgot Password' in res.text - assert res.get_form('forgotPasswordForm') - - # test that existing user can receive reset password email - def test_can_receive_reset_password_email(self): - # load forgot password page and submit email - res = self.app.get(self.get_url) - form = res.get_form('forgotPasswordForm') - form['forgot_password-email'] = self.user.username - res = form.submit(self.app) - - # check mail was sent - assert self.mock_send_grid.called - # check http 200 response - assert res.status_code == 200 - # check request URL is /forgotpassword - assert res.request.path == self.post_url - # check push notification - assert_in_html('If there is an OSF account', res.text) - assert_not_in_html('Please wait', res.text) - - # check verification_key_v2 is set - self.user.reload() - assert self.user.verification_key_v2 != {} - - # test that non-existing user cannot receive reset password email - def test_cannot_receive_reset_password_email(self): - # load forgot password page and submit email - res = self.app.get(self.get_url) - form = res.get_form('forgotPasswordForm') - form['forgot_password-email'] = 'fake' + self.user.username - res = form.submit(self.app) - - # check mail was not sent - assert not self.mock_send_grid.called - # check http 200 response - assert res.status_code == 200 - # check request URL is /forgotpassword - assert res.request.path == self.post_url - # check push notification - assert_in_html('If there is an OSF account', res.text) - assert_not_in_html('Please wait', res.text) - - # check verification_key_v2 is not set - self.user.reload() - assert self.user.verification_key_v2 == {} - - # test that non-existing user cannot receive reset password email - def test_not_active_user_no_reset_password_email(self): - self.user.deactivate_account() - self.user.save() - - # load forgot password page and submit email - res = self.app.get(self.get_url) - form = res.get_form('forgotPasswordForm') - form['forgot_password-email'] = self.user.username - res = form.submit(self.app) - - # check mail was not sent - assert not self.mock_send_grid.called - # check http 200 response - assert res.status_code == 200 - # check request URL is /forgotpassword - assert res.request.path == self.post_url - # check push notification - assert_in_html('If there is an OSF account', res.text) - assert_not_in_html('Please wait', res.text) - - # check verification_key_v2 is not set - self.user.reload() - assert self.user.verification_key_v2 == {} - - # test that user cannot submit forgot password request too quickly - def test_cannot_reset_password_twice_quickly(self): - # load forgot password page and submit email - res = self.app.get(self.get_url) - form = res.get_form('forgotPasswordForm') - form['forgot_password-email'] = self.user.username - res = form.submit(self.app) - res = form.submit(self.app) - - # check http 200 response - assert res.status_code == 200 - # check push notification - assert_in_html('Please wait', res.text) - assert_not_in_html('If there is an OSF account', res.text) - - -@mock.patch('website.mails.settings.USE_EMAIL', True) -@mock.patch('website.mails.settings.USE_CELERY', False) -class TestForgotPasswordInstitution(OsfTestCase): - - def setUp(self): - super().setUp() - self.user = UserFactory() - self.auth_user = AuthUserFactory() - self.get_url = web_url_for('redirect_unsupported_institution') - self.post_url = web_url_for('forgot_password_institution_post') - self.user.verification_key_v2 = {} - self.user.save() - - self.mock_send_grid = start_mock_send_grid(self) - - # log users out before they land on institutional forgot password page - def test_forgot_password_logs_out_user(self): - # TODO: check in qa url encoding - # visit forgot password link while another user is logged in - res = self.app.get(self.get_url, auth=self.auth_user.auth) - # check redirection to CAS logout - assert res.status_code == 302 - location = res.headers.get('Location') - assert quote_plus('campaign=unsupportedinstitution') in location - assert 'logout?service=' in location - - # test that institutional forgot password page redirects to CAS unsupported - # institution page - def test_get_forgot_password(self): - res = self.app.get(self.get_url) - assert res.status_code == 302 - location = res.headers.get('Location') - assert 'campaign=unsupportedinstitution' in location - - # test that user from disabled institution can receive reset password email - def test_can_receive_reset_password_email(self): - # submit email to institutional forgot-password page - res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) - - # check mail was sent - assert self.mock_send_grid.called - # check http 200 response - assert res.status_code == 200 - # check request URL is /forgotpassword - assert res.request.path == self.post_url - # check push notification - assert_in_html('If there is an OSF account', res.text) - assert_not_in_html('Please wait', res.text) - - # check verification_key_v2 is set - self.user.reload() - assert self.user.verification_key_v2 != {} - - # test that non-existing user cannot receive reset password email - def test_cannot_receive_reset_password_email(self): - # load forgot password page and submit email - res = self.app.post(self.post_url, data={'forgot_password-email': 'fake' + self.user.username}) - - # check mail was not sent - assert not self.mock_send_grid.called - # check http 200 response - assert res.status_code == 200 - # check request URL is /forgotpassword-institution - assert res.request.path == self.post_url - # check push notification - assert_in_html('If there is an OSF account', res.text) - assert_not_in_html('Please wait', res.text) - - # check verification_key_v2 is not set - self.user.reload() - assert self.user.verification_key_v2 == {} - - # test that non-existing user cannot receive institutional reset password email - def test_not_active_user_no_reset_password_email(self): - self.user.deactivate_account() - self.user.save() - - res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) - - # check mail was not sent - assert not self.mock_send_grid.called - # check http 200 response - assert res.status_code == 200 - # check request URL is /forgotpassword-institution - assert res.request.path == self.post_url - # check push notification - assert_in_html('If there is an OSF account', res.text) - assert_not_in_html('Please wait', res.text) - - # check verification_key_v2 is not set - self.user.reload() - assert self.user.verification_key_v2 == {} - - # test that user cannot submit forgot password request too quickly - def test_cannot_reset_password_twice_quickly(self): - # submit institutional forgot-password request in rapid succession - res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) - res = self.app.post(self.post_url, data={'forgot_password-email': self.user.username}) - - # check http 200 response - assert res.status_code == 200 - # check push notification - assert_in_html('Please wait', res.text) - assert_not_in_html('If there is an OSF account', res.text) - - -@unittest.skip('Public projects/components are dynamically loaded now.') -class TestAUserProfile(OsfTestCase): - - def setUp(self): - OsfTestCase.setUp(self) - - self.user = AuthUserFactory() - self.me = AuthUserFactory() - self.project = ProjectFactory(creator=self.me, is_public=True, title=fake.bs()) - self.component = NodeFactory(creator=self.me, parent=self.project, is_public=True, title=fake.bs()) - - # regression test for https://github.com/CenterForOpenScience/osf.io/issues/2623 - def test_has_public_projects_and_components(self): - # I go to my own profile - url = web_url_for('profile_view_id', uid=self.me._primary_key) - # I see the title of both my project and component - res = self.app.get(url, auth=self.me.auth) - assert_in_html(self.component.title, res) - assert_in_html(self.project.title, res) - - # Another user can also see my public project and component - url = web_url_for('profile_view_id', uid=self.me._primary_key) - # I see the title of both my project and component - res = self.app.get(url, auth=self.user.auth) - assert_in_html(self.component.title, res) - assert_in_html(self.project.title, res) - - def test_shows_projects_with_many_contributors(self): - # My project has many contributors - for _ in range(5): - user = UserFactory() - self.project.add_contributor(user, auth=Auth(self.project.creator), save=True) - - # I go to my own profile - url = web_url_for('profile_view_id', uid=self.me._primary_key) - res = self.app.get(url, auth=self.me.auth) - # I see '3 more' as a link - assert '3 more' in res.text - - res = res.click('3 more') - assert res.request.path == self.project.url - - def test_has_no_public_projects_or_components_on_own_profile(self): - # User goes to their profile - url = web_url_for('profile_view_id', uid=self.user._id) - res = self.app.get(url, auth=self.user.auth) - - # user has no public components/projects - assert 'You have no public projects' in res - assert 'You have no public components' in res - - def test_user_no_public_projects_or_components(self): - # I go to other user's profile - url = web_url_for('profile_view_id', uid=self.user._id) - # User has no public components/projects - res = self.app.get(url, auth=self.me.auth) - assert 'This user has no public projects' in res - assert 'This user has no public components'in res - - # regression test - def test_does_not_show_registrations(self): - project = ProjectFactory(creator=self.user) - component = NodeFactory(parent=project, creator=self.user, is_public=False) - # User has a registration with public components - reg = RegistrationFactory(project=component.parent_node, creator=self.user, is_public=True) - for each in reg.nodes: - each.is_public = True - each.save() - # I go to other user's profile - url = web_url_for('profile_view_id', uid=self.user._id) - # Registration does not appear on profile - res = self.app.get(url, auth=self.me.auth) - assert 'This user has no public components' in res - assert reg.title not in res - assert reg.nodes[0].title not in res - - @pytest.mark.enable_bookmark_creation class TestPreprintBannerView(OsfTestCase): def setUp(self): @@ -1250,6 +620,7 @@ def test_public_project_pending_preprint_post_moderation(self): res = self.app.get(url, auth=self.admin.auth) assert f'{self.preprint.provider.name}' in res.text assert 'Pending\n' in res.text + print('res.text', res.text) assert 'This preprint is publicly available and searchable but is subject to removal by a moderator.' in res.text # Write - preprint diff --git a/tests/utils.py b/tests/utils.py index 6d5f934d8ba..d1e9adf1cd9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,18 +1,28 @@ -import contextlib +import copy +from collections import Counter import datetime import functools -from unittest import mock +from unittest import mock, SkipTest # added SkipTest import +import requests +import waffle +import contextlib +from typing import Any, Optional +from django.apps import apps from django.http import HttpRequest from django.utils import timezone from framework.auth import Auth from framework.celery_tasks.handlers import celery_teardown_request +from osf.email import _render_email_html from osf_tests.factories import DraftRegistrationFactory -from osf.models import Sanction +from osf.models import Sanction, NotificationType from tests.base import get_default_metaschema from website.archiver import ARCHIVER_SUCCESS from website.archiver import listeners as archiver_listeners +from website import settings as website_settings +from osf import features + def requires_module(module): def decorator(fn): @@ -28,18 +38,7 @@ def wrapper(*args, **kwargs): def assert_logs(log_action, node_key, index=-1): - """A decorator to ensure a log is added during a unit test. - :param str log_action: NodeLog action - :param str node_key: key to get Node instance from self - :param int index: list index of log to check against - - Example usage: - @assert_logs(NodeLog.UPDATED_FIELDS, 'node') - def test_update_node(self): - self.node.update({'title': 'New Title'}, auth=self.auth) - - TODO: extend this decorator to check log param correctness? - """ + """Ensure a log is added during a unit test.""" def outer_wrapper(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): @@ -54,19 +53,9 @@ def wrapper(self, *args, **kwargs): return wrapper return outer_wrapper -def assert_preprint_logs(log_action, preprint_key, index=-1): - """A decorator to ensure a log is added during a unit test. - :param str log_action: PreprintLog action - :param str preprint_key: key to get Preprint instance from self - :param int index: list index of log to check against - - Example usage: - @assert_logs(PreprintLog.UPDATED_FIELDS, 'preprint') - def test_update_preprint(self): - self.preprint.update({'title': 'New Title'}, auth=self.auth) - TODO: extend this decorator to check log param correctness? - """ +def assert_preprint_logs(log_action, preprint_key, index=-1): + """Ensure a preprint log is added during a unit test.""" def outer_wrapper(func): @functools.wraps(func) def wrapper(self, *args, **kwargs): @@ -81,6 +70,7 @@ def wrapper(self, *args, **kwargs): return wrapper return outer_wrapper + def assert_not_logs(log_action, node_key, index=-1): def outer_wrapper(func): @functools.wraps(func) @@ -96,92 +86,75 @@ def wrapper(self, *args, **kwargs): return wrapper return outer_wrapper + def assert_equals(item_one, item_two): item_one.sort() item_two.sort() assert item_one == item_two + @contextlib.contextmanager -def assert_latest_log(log_action, node_key, index=0): - node = node_key +def assert_latest_log(log_action, node, index=0): + """Assert the latest log on `node` matches `log_action`.""" last_log = node.logs.latest() node.reload() yield - new_log = node.logs.order_by('-date')[index] if hasattr(last_log, 'date') else node.logs.order_by('-created')[index] + # Prefer `date` if present on last_log, otherwise `created` + if hasattr(last_log, 'date'): + new_log = node.logs.order_by('-date')[index] + else: + new_log = node.logs.order_by('-created')[index] assert last_log._id != new_log._id assert new_log.action == log_action + @contextlib.contextmanager -def assert_latest_log_not(log_action, node_key, index=0): - node = node_key +def assert_latest_log_not(log_action, node, index=0): + """Assert the latest log on `node` does NOT match `log_action`.""" last_log = node.logs.latest() node.reload() yield - new_log = node.logs.order_by('-date')[index] if hasattr(last_log, 'date') else node.logs.order_by('-created')[index] + if hasattr(last_log, 'date'): + new_log = node.logs.order_by('-date')[index] + else: + new_log = node.logs.order_by('-created')[index] assert new_log.action != log_action assert last_log._id == new_log._id + @contextlib.contextmanager def mock_archive(project, schema=None, auth=None, draft_registration=None, parent=None, embargo=False, embargo_end_date=None, retraction=False, justification=None, autoapprove_retraction=False, autocomplete=True, autoapprove=False): - """ A context manager for registrations. When you want to call Node#register_node in - a test but do not want to deal with any of this side effects of archiver, this - helper allows for creating a registration in a safe fashion. - - :param bool embargo: embargo the registration (rather than RegistrationApproval) - :param bool autocomplete: automatically finish archival? - :param bool autoapprove: automatically approve registration approval? - :param bool retraction: retract the registration? - :param str justification: a justification for the retraction - :param bool autoapprove_retraction: automatically approve retraction? - - Example use: - - project = ProjectFactory() - with mock_archive(project) as registration: - assert_true(registration.is_registration) - assert_true(registration.archiving) - assert_true(registration.is_pending_registration) - - with mock_archive(project, autocomplete=True) as registration: - assert_true(registration.is_registration) - assert_false(registration.archiving) - assert_true(registration.is_pending_registration) - - with mock_archive(project, autocomplete=True, autoapprove=True) as registration: - assert_true(registration.is_registration) - assert_false(registration.archiving) - assert_false(registration.is_pending_registration) + """ + Context manager to create a registration without archiver side effects. """ schema = schema or get_default_metaschema() auth = auth or Auth(project.creator) - draft_registration = draft_registration or DraftRegistrationFactory(branched_from=project, registration_schema=schema) + draft_registration = draft_registration or DraftRegistrationFactory( + branched_from=project, registration_schema=schema + ) with mock.patch('framework.celery_tasks.handlers.enqueue_task'): registration = draft_registration.register(auth=auth, save=True) if embargo: - embargo_end_date = embargo_end_date or ( - timezone.now() + datetime.timedelta(days=20) - ) - registration.root.embargo_registration( - project.creator, - embargo_end_date - ) + embargo_end_date = embargo_end_date or (timezone.now() + datetime.timedelta(days=20)) + registration.root.embargo_registration(project.creator, embargo_end_date) else: registration.root.require_approval(project.creator) + if autocomplete: root_job = registration.root.archive_job root_job.status = ARCHIVER_SUCCESS root_job.sent = False root_job.done = True root_job.save() - sanction = registration.root.sanction - mock.patch.object(root_job, 'archive_tree_finished', mock.Mock(return_value=True)) - mock.patch('website.archiver.tasks.archive_success.delay', mock.Mock()) - archiver_listeners.archive_callback(registration) + # Ensure patches actually apply: + with mock.patch.object(root_job, 'archive_tree_finished', mock.Mock(return_value=True)), \ + mock.patch('website.archiver.tasks.archive_success.delay', mock.Mock()): + archiver_listeners.archive_callback(registration) if autoapprove: sanction = registration.root.sanction @@ -197,57 +170,412 @@ def mock_archive(project, schema=None, auth=None, draft_registration=None, paren registration.save() yield registration + def make_drf_request(*args, **kwargs): from rest_framework.request import Request http_request = HttpRequest() - # The values here don't matter; they just need - # to be present + http_request.method = 'GET' + http_request.path = '/' http_request.META['SERVER_NAME'] = 'localhost' - http_request.META['SERVER_PORT'] = 8000 - # A DRF Request wraps a Django HttpRequest + http_request.META['SERVER_PORT'] = '8000' # ensure string return Request(http_request, *args, **kwargs) + def make_drf_request_with_version(version='2.0', *args, **kwargs): req = make_drf_request(*args, **kwargs) - req.parser_context['kwargs'] = {'version': 'v2'} + req.parser_context.setdefault('kwargs', {}) + req.parser_context['kwargs']['version'] = 'v2' req.version = version return req -class MockAuth: +class MockAuth: def __init__(self, user): self.user = user self.logged_in = True self.private_key = None self.private_link = None -mock_auth = lambda user: mock.patch('framework.auth.Auth.from_kwargs', mock.Mock(return_value=MockAuth(user))) + +mock_auth = lambda user: mock.patch( + 'framework.auth.Auth.from_kwargs', + mock.Mock(return_value=MockAuth(user)) +) + def unique(factory): """ - Turns a factory function into a new factory function that guarentees unique return - values. Note this uses regular item equivalence to check uniqueness, so this may not - behave as expected with factories with complex return values. - - Example use: - unique_name_factory = unique(fake.name) - unique_name = unique_name_factory() + Turn a factory function into one that guarantees unique return values. """ used = [] @functools.wraps(factory) def wrapper(): item = factory() - over = 0 + attempts = 0 while item in used: - if over > 100: - raise RuntimeError('Tried 100 times to generate a unqiue value, stopping.') + if attempts > 100: + raise RuntimeError('Tried 100 times to generate a unique value, stopping.') item = factory() - over += 1 + attempts += 1 used.append(item) return item return wrapper + @contextlib.contextmanager def run_celery_tasks(): yield celery_teardown_request() + +import re, html as html_lib, difflib + + +# Matches a wide range of ISO-like datetimes (with optional microseconds and timezone) +_ISO_DT = re.compile( + r'\b\d{4}-\d{2}-\d{2}[ T]' + r'\d{2}:\d{2}:\d{2}(?:\.\d+)?' + r'(?:Z|[+-]\d{2}:?\d{2}|[+-]\d{4}|(?:\s*UTC)|(?:\s*\+\d{2}:\d{2}))?\b' +) + +# Matches tuple-ish renderings like: ('2025-09-02 11:58:52.741685+00:00',) +_TUPLE_WRAP = re.compile(r"\(\s*'([^']+)'\s*,?\s*\)") + +def _canon_html(s: str) -> str: + s = html_lib.unescape(s or '') + # normalize newlines/whitespace around tags + s = s.replace('\r\n', '\n').replace('\r', '\n') + s = re.sub(r'>\s+<', '><', s) + s = re.sub(r'\s+', ' ', s).strip() + + # 1) unwrap tuple-like timestamp values: ('…',) -> … + s = _TUPLE_WRAP.sub(r'\1', s) + + # 2) normalize any iso-ish datetime to a stable token + s = _ISO_DT.sub('<>', s) + + return s + +@contextlib.contextmanager +def capture_notifications(capture_email: bool = True, passthrough: bool = False): + """ + Capture NotificationType.emit calls and (optionally) email sends. + Surfaces helpful template errors if rendering fails. + """ + try: + from osf.email import _extract_vars as _extract_template_vars + except Exception: + _extract_template_vars = None + + NotificationTypeModel = apps.get_model('osf', 'NotificationType') + captured = {'emits': [], 'emails': []} + + # Patch the instance method so ALL emit paths are captured + _real_emit = NotificationTypeModel.emit + + def _wrapped_emit(self, *emit_args, **emit_kwargs): + # deep-copy dict-like contexts so later mutations won’t affect captures + ek = dict(emit_kwargs) + if isinstance(ek.get('event_context'), dict): + ek['event_context'] = copy.deepcopy(ek['event_context']) + if isinstance(ek.get('email_context'), dict): + ek['email_context'] = copy.deepcopy(ek['email_context']) + + captured['emits'].append({ + 'type': getattr(self, 'name', None), + 'args': emit_args, + 'kwargs': ek, + }) + if passthrough: + return _real_emit(self, *emit_args, **ek) + + patches = [ + mock.patch('osf.models.notification_type.NotificationType.emit', new=_wrapped_emit), + ] + + if capture_email: + from osf import email as _osf_email + _real_send_over_smtp = _osf_email.send_email_over_smtp + _real_send_with_sendgrid = _osf_email.send_email_with_send_grid + + def _fake_send_over_smtp(to_email, notification_type, context=None, email_context=None): + captured['emails'].append({ + 'protocol': 'smtp', + 'to': to_email, + 'notification_type': notification_type, + 'context': context.copy() if isinstance(context, dict) else context, + 'email_context': email_context.copy() if isinstance(email_context, dict) else email_context, + }) + if passthrough: + return _real_send_over_smtp(to_email, notification_type, context, email_context) + + def _fake_send_with_sendgrid(user, notification_type, context=None, email_context=None): + captured['emails'].append({ + 'protocol': 'sendgrid', + 'to': user, + 'notification_type': notification_type, + 'context': context.copy() if isinstance(context, dict) else context, + 'email_context': email_context.copy() if isinstance(email_context, dict) else email_context, + }) + if passthrough: + return _real_send_with_sendgrid(user, notification_type, context, email_context) + + patches.extend([ + mock.patch('osf.email.send_email_over_smtp', new=_fake_send_over_smtp), + mock.patch('osf.email.send_email_with_send_grid', new=_fake_send_with_sendgrid), + ]) + + with contextlib.ExitStack() as stack: + for p in patches: + stack.enter_context(p) + yield captured + + if not captured['emits']: + raise AssertionError( + 'No notifications were emitted. ' + 'Expected at least one call to NotificationType.emit. ' + 'Tip: ensure your code path triggers an emit and that patches did not get overridden.' + ) + + # Validate each captured emit renders (to catch missing template vars early) + for idx, rec in enumerate(captured.get('emits', []), start=1): + nt = NotificationType.objects.get(name=rec.get('type')) + template_text = getattr(nt, 'template', '') or '' + ctx = rec['kwargs'].get('event_context', {}) or {} + try: + rendered = _render_email_html(nt, ctx) + except Exception as e: + # Try to hint at missing variables if possible + missing_hint = '' + if _extract_template_vars and isinstance(ctx, dict): + try: + needed = set(_extract_template_vars(template_text)) + missing = sorted(v for v in needed if v not in ctx) + if missing: + missing_hint = f' Missing variables: {missing}.' + except Exception: + pass + raise AssertionError( + f'Email render failed for notification "{getattr(nt, "name", "(unknown)")}" ' + f'with error: {type(e).__name__}: {e}.{missing_hint}' + ) from e + + # Fail if rendering produced nothing + if not isinstance(rendered, str) or not rendered.strip(): + missing_hint = '' + if _extract_template_vars and isinstance(ctx, dict): + try: + needed = set(_extract_template_vars(template_text)) + missing = sorted(v for v in needed if v not in ctx) + if missing: + missing_hint = f' Likely missing variables: {missing}.' + except Exception: + pass + raise AssertionError( + f'Email render produced empty/blank HTML for notification "{getattr(nt, "name", "(unknown)")}".' + f'{missing_hint}' + ) + + # Fail if rendering just echoed the raw template text (Mako likely failed) + if template_text and rendered.strip() == template_text.strip(): + raise AssertionError( + f'Email render returned the raw template (no interpolation) for ' + f'"{getattr(nt, "name", "(unknown)")}"; template rendering likely failed.' + ) + + +def get_mailhog_messages(): + """Fetch messages from MailHog API.""" + if not waffle.switch_is_active(features.ENABLE_MAILHOG): + return {'count': 0, 'items': []} + mailhog_url = f'{website_settings.MAILHOG_API_HOST}/api/v2/messages' + response = requests.get(mailhog_url) + if response.status_code == 200: + return response.json() + return {'count': 0, 'items': []} + + +def delete_mailhog_messages(): + """Delete all messages from MailHog.""" + if not waffle.switch_is_active(features.ENABLE_MAILHOG): + return + mailhog_url = f'{website_settings.MAILHOG_API_HOST}/api/v1/messages' + requests.delete(mailhog_url) + + +def assert_emails(mailhog_messages, notifications): + """ + Compare rendered expected HTML vs MailHog actual HTML in a deterministic way. + We sort by recipient to avoid flaky ordering differences. + """ + # Build expected list [(recipient, html)] + expected = [] + for item in notifications['emits']: + to_username = item['kwargs']['user'].username + nt = NotificationType.objects.get(name=item['type']) + html = _render_email_html(nt, item['kwargs']['event_context']) + expected.append((to_username, _canon_html(html))) + + # Build actual list [(recipient, html)] + actual = [] + for msg in mailhog_messages.get('items', []): + to_addr = msg['Content']['Headers']['To'][0] + body = msg['Content']['Body'] + actual.append((to_addr, _canon_html(body))) + + # Sort and compare + expected_sorted = sorted(expected, key=lambda x: x[0]) + actual_sorted = sorted(actual, key=lambda x: x[0]) + + exp_html = [h for _, h in expected_sorted] + act_html = [h for _, h in actual_sorted] + exp_to = [r for r, _ in expected_sorted] + act_to = [r for r, _ in actual_sorted] + + if exp_html != act_html: + # helpful diff for the first mismatch + for i, (eh, ah) in enumerate(zip(exp_html, act_html)): + if eh != ah: + diff = '\n'.join(difflib.unified_diff(eh.split(), ah.split(), lineterm='')) + raise AssertionError( + f"Rendered HTML bodies differ (sorted by recipient) at index {i} " + f"({exp_to[i]}):\n{diff}" + ) + assert exp_to == act_to, 'Recipient lists differ (sorted).' + +def _notif_type_name(t: Any) -> str: + """Normalize a NotificationType-ish input to its lowercase name.""" + if t is None: + return '' + n = getattr(t, 'name', None) + if n: + return str(n).strip().lower() + for attr in ('value', 'NAME', 'name'): + if hasattr(t, attr): + try: + return str(getattr(t, attr)).strip().lower() + except Exception: + pass + return str(t).strip().lower() + + +def _safe_user_id(u: Any) -> Optional[str]: + """Normalize user object to stable identifier.""" + if u is None: + return None + for attr in ('_id', 'id', 'guids', 'guid', 'pk'): + if hasattr(u, attr): + try: + val = getattr(u, attr) + if hasattr(val, 'first'): + g = val.first() + if g and hasattr(g, '_id'): + return g._id + if isinstance(val, (str, int)): + return str(val) + except Exception: + pass + return f'obj:{id(u)}' + + +def _safe_obj_id(o: Any) -> Optional[str]: + """Normalize object to stable identifier.""" + if o is None: + return None + for attr in ('_id', 'id', 'guid', 'guids', 'pk'): + if hasattr(o, attr): + try: + val = getattr(o, attr) + if hasattr(val, 'first'): + g = val.first() + if g and hasattr(g, '_id'): + return g._id + if isinstance(val, (str, int)): + return str(val) + except Exception: + pass + return f'obj:{id(o)}' + + +@contextlib.contextmanager +def assert_notification( + *, + type, # NotificationType, NotificationType.Type, or str + user: Any = None, # optional user object to match + subscribed_object: Any = None, # optional object (e.g., node) to match + times: int = 1, # exact number of emits expected + at_least: bool = False, # if True, assert >= times instead of == times + assert_email: Optional[bool] = None, # True: must send email; False: must not; None: ignore + passthrough: bool = False # pass emails through to real senders if desired +): + """ + Usage: + with assert_notification(type=NotificationType.Type.NODE_FORK_COMPLETED, user=self.user): + + """ + expected_type = _notif_type_name(type) + expected_user_id = _safe_user_id(user) if user is not None else None + expected_obj_id = _safe_obj_id(subscribed_object) if subscribed_object is not None else None + + # Capture emits (and optionally email) while the code under test runs + with capture_notifications(capture_email=(assert_email is not False), passthrough=passthrough) as cap: + yield cap + + # ---- Filter emits by criteria ---- + def _emit_matches(e) -> bool: + if expected_type and str(e.get('type', '')).strip().lower() != expected_type: + return False + kw = e.get('kwargs', {}) + if user is not None: + u = kw.get('user') + if u is None or _safe_user_id(u) != expected_user_id: + return False + if subscribed_object is not None: + so = kw.get('subscribed_object') + if so is None or _safe_obj_id(so) != expected_obj_id: + return False + return True + + matching_emits = [e for e in cap.get('emits', []) if _emit_matches(e)] + count = len(matching_emits) + + if at_least: + assert count >= times, ( + f'Expected at least {times} emits of type "{expected_type}"' + f'{f" for user {expected_user_id}" if user is not None else ""}' + f'{f" and object {expected_obj_id}" if subscribed_object is not None else ""}, ' + f'but saw {count}. All emits: {cap.get("emits", [])}' + ) + else: + assert count == times, ( + f'Expected exactly {times} emits of type "{expected_type}"' + f'{f" for user {expected_user_id}" if user is not None else ""}' + f'{f" and object {expected_obj_id}" if subscribed_object is not None else ""}, ' + f'but saw {count}. All emits: {cap.get("emits", [])}' + ) + + # ---- Optional email assertions ---- + if assert_email is not None: + def _email_matches(rec) -> bool: + nt = rec.get('notification_type') + name = getattr(nt, 'name', None) + if not name or name.strip().lower() != expected_type: + return False + if user is not None: + to_field = rec.get('to') + if hasattr(to_field, '_id') or hasattr(to_field, 'id'): + return _safe_user_id(to_field) == expected_user_id + return True + + email_matches = [r for r in cap.get('emails', []) if _email_matches(r)] + if assert_email: + assert email_matches, ( + f'Expected an email for notification "{expected_type}"' + f'{f" to user {expected_user_id}" if user is not None else ""} ' + f'but none were captured. All emails: {cap.get("emails", [])}' + ) + else: + assert not email_matches, ( + f'Expected NO email for notification "{expected_type}"' + f'{f" to user {expected_user_id}" if user is not None else ""} ' + f'but captured: {email_matches}' + ) diff --git a/website/app.py b/website/app.py index 5db655a2164..b026b5f7098 100644 --- a/website/app.py +++ b/website/app.py @@ -19,8 +19,7 @@ from framework.transactions import handlers as transaction_handlers # Imports necessary to connect signals from website.archiver import listeners # noqa -from website.mails import listeners # noqa -from website.notifications import listeners # noqa +from notifications import listeners # noqa from website.identifiers import listeners # noqa from website.reviews import listeners # noqa from werkzeug.middleware.proxy_fix import ProxyFix diff --git a/website/archiver/utils.py b/website/archiver/utils.py index 44cd7517413..0caa9e81d3b 100644 --- a/website/archiver/utils.py +++ b/website/archiver/utils.py @@ -6,10 +6,7 @@ from framework.auth import Auth from framework.utils import sanitize_html -from website import ( - mails, - settings -) +from website import settings from website.archiver import ( StatResult, AggregateStatResult, ARCHIVER_NETWORK_ERROR, @@ -17,6 +14,7 @@ ARCHIVER_FILE_NOT_FOUND, ARCHIVER_FORCED_FAILURE, ) +from website.settings import MAX_ARCHIVE_SIZE FILE_HTML_LINK_TEMPLATE = settings.DOMAIN + 'project/{registration_guid}/files/osfstorage/{file_id}' FILE_DOWNLOAD_LINK_TEMPLATE = settings.DOMAIN + 'download/{file_id}' @@ -29,79 +27,123 @@ def normalize_unicode_filenames(filename): def send_archiver_size_exceeded_mails(src, user, stat_result, url): - mails.send_mail( - to_addr=settings.OSF_SUPPORT_EMAIL, - mail=mails.ARCHIVE_SIZE_EXCEEDED_DESK, + from osf.models.notification_type import NotificationType + + NotificationType.Type.DESK_ARCHIVE_JOB_EXCEEDED.instance.emit( user=user, - src=src, - stat_result=stat_result, - can_change_preferences=False, - url=url, + subscribed_object=src, + event_context={ + 'user_fullname': user.fullname, + 'user__id': user._id, + 'src__id': src._id, + 'src_url': src.url, + 'src_title': src.title, + 'stat_result': stat_result, + 'url': url, + 'max_archive_size': MAX_ARCHIVE_SIZE / 1024 ** 3, + 'can_change_preferences': False, + } ) - mails.send_mail( - to_addr=user.username, - mail=mails.ARCHIVE_SIZE_EXCEEDED_USER, + NotificationType.Type.USER_ARCHIVE_JOB_EXCEEDED.instance.emit( user=user, - src=src, - can_change_preferences=False, + subscribed_object=user, + event_context={ + 'user_fullname': user.fullname, + 'user__id': user._id, + 'src_title': src.title, + 'src_url': src.url, + 'max_archive_size': MAX_ARCHIVE_SIZE / 1024 ** 3, + 'can_change_preferences': False, + } ) def send_archiver_copy_error_mails(src, user, results, url): - mails.send_mail( - to_addr=settings.OSF_SUPPORT_EMAIL, - mail=mails.ARCHIVE_COPY_ERROR_DESK, + from osf.models.notification_type import NotificationType + + NotificationType.Type.DESK_ARCHIVE_JOB_COPY_ERROR.instance.emit( user=user, - src=src, - results=results, - url=url, - can_change_preferences=False, + event_context={ + 'domain': settings.DOMAIN, + 'user_fullname': user.fullname, + 'user__id': user._id, + 'src__id': src._id, + 'src_url': src.url, + 'src_title': src.title, + 'results': results, + 'url': url, + 'can_change_preferences': False, + } ) - mails.send_mail( - to_addr=user.username, - mail=mails.ARCHIVE_COPY_ERROR_USER, + NotificationType.Type.USER_ARCHIVE_JOB_COPY_ERROR.instance.emit( user=user, - src=src, - results=results, - can_change_preferences=False, + event_context={ + 'domain': settings.DOMAIN, + 'user_fullname': user.fullname, + 'user__id': user._id, + 'src__id': src._id, + 'src_url': src.url, + 'src_title': src.title, + 'results': results, + 'can_change_preferences': False, + } ) def send_archiver_file_not_found_mails(src, user, results, url): - mails.send_mail( - to_addr=settings.OSF_SUPPORT_EMAIL, - mail=mails.ARCHIVE_FILE_NOT_FOUND_DESK, - can_change_preferences=False, - user=user, - src=src, - results=results, - url=url, + from osf.models.notification_type import NotificationType + + NotificationType.Type.DESK_ARCHIVE_JOB_FILE_NOT_FOUND.instance.emit( + destination_address=settings.OSF_SUPPORT_EMAIL, + event_context={ + 'user': user.id, + 'src': src._id, + 'results': results, + 'url': url, + 'can_change_preferences': False, + } ) - mails.send_mail( - to_addr=user.username, - mail=mails.ARCHIVE_FILE_NOT_FOUND_USER, + NotificationType.Type.USER_ARCHIVE_JOB_FILE_NOT_FOUND.instance.emit( user=user, - src=src, - results=results, - can_change_preferences=False, + event_context={ + 'user': user.id, + 'src': src._id, + 'src_title': src.title, + 'src_url': src.url, + 'results': results, + 'can_change_preferences': False, + } ) def send_archiver_uncaught_error_mails(src, user, results, url): - mails.send_mail( - to_addr=settings.OSF_SUPPORT_EMAIL, - mail=mails.ARCHIVE_UNCAUGHT_ERROR_DESK, - user=user, - src=src, - results=results, - can_change_preferences=False, - url=url, + from osf.models.notification_type import NotificationType + + NotificationType.Type.DESK_ARCHIVE_JOB_UNCAUGHT_ERROR.instance.emit( + destination_address=settings.OSF_SUPPORT_EMAIL, + event_context={ + 'user_fullname': user.fullname, + 'user__id': user._id, + 'user_username': user.username, + 'src_title': src.title, + 'src__id': src._id, + 'src_url': src.url, + 'src': src._id, + 'results': [str(error) for error in results], + 'url': url, + 'can_change_preferences': False, + } ) - mails.send_mail( - to_addr=user.username, - mail=mails.ARCHIVE_UNCAUGHT_ERROR_USER, - user=user, - src=src, - results=results, - can_change_preferences=False, + NotificationType.Type.USER_ARCHIVE_JOB_UNCAUGHT_ERROR.instance.emit( + destination_address=settings.OSF_SUPPORT_EMAIL, + event_context={ + 'user_fullname': user.fullname, + 'user__id': user._id, + 'src_title': src.title, + 'src__id': src._id, + 'src_url': src.url, + 'src': src._id, + 'results': [str(error) for error in results], + 'can_change_preferences': False, + } ) diff --git a/website/conferences/views.py b/website/conferences/views.py index cf7dbfd6d3b..913c991cda9 100644 --- a/website/conferences/views.py +++ b/website/conferences/views.py @@ -1,13 +1,11 @@ from rest_framework import status as http_status import logging -from flask import request -import waffle from django.db import transaction, connection from django.contrib.contenttypes.models import ContentType from framework.auth import get_or_create_user -from framework.exceptions import HTTPError, ServiceDiscontinuedError +from framework.exceptions import HTTPError from framework.flask import redirect from framework.transactions.handlers import no_auto_transaction from osf import features @@ -16,8 +14,6 @@ from website.conferences import utils from website.conferences.message import ConferenceMessage, ConferenceError from website.ember_osf_web.decorators import ember_flag_is_active -from website.mails import CONFERENCE_SUBMITTED, CONFERENCE_INACTIVE, CONFERENCE_FAILED, CONFERENCE_DEPRECATION -from website.mails import send_mail from website.util import web_url_for from website.util.metrics import CampaignSourceTags @@ -30,17 +26,6 @@ def meeting_hook(): """ message = ConferenceMessage() - if waffle.flag_is_active(request, features.DISABLE_MEETINGS): - send_mail( - message.sender_email, - CONFERENCE_DEPRECATION, - fullname=message.sender_display, - support_email=settings.OSF_SUPPORT_EMAIL, - can_change_preferences=False, - logo=settings.OSF_MEETINGS_LOGO, - ) - raise ServiceDiscontinuedError() - try: message.verify() except ConferenceError as error: @@ -54,14 +39,6 @@ def meeting_hook(): raise HTTPError(http_status.HTTP_406_NOT_ACCEPTABLE) if not conference.active: - send_mail( - message.sender_email, - CONFERENCE_INACTIVE, - fullname=message.sender_display, - presentations_url=web_url_for('conference_view', _absolute=True), - can_change_preferences=False, - logo=settings.OSF_MEETINGS_LOGO, - ) raise HTTPError(http_status.HTTP_406_NOT_ACCEPTABLE) add_poster_by_email(conference=conference, message=message) @@ -72,16 +49,6 @@ def add_poster_by_email(conference, message): :param Conference conference: :param ConferenceMessage message: """ - # Fail if no attachments - if not message.attachments: - return send_mail( - message.sender_email, - CONFERENCE_FAILED, - fullname=message.sender_display, - can_change_preferences=False, - logo=settings.OSF_MEETINGS_LOGO - ) - with transaction.atomic(): user, user_created = get_or_create_user( message.sender_display, @@ -97,16 +64,6 @@ def add_poster_by_email(conference, message): user.update_date_last_login() user.save() - # must save the user first before accessing user._id - set_password_url = web_url_for( - 'reset_password_get', - uid=user._id, - token=user.verification_key_v2['token'], - _absolute=True, - ) - else: - set_password_url = None - # Always create a new meeting node node = Node.objects.create( title=message.subject, @@ -125,36 +82,6 @@ def add_poster_by_email(conference, message): utils.upload_attachments(user, node, message.attachments) - download_url = node.web_url_for( - 'addon_view_or_download_file', - path=message.attachments[0].filename, - provider='osfstorage', - action='download', - _absolute=True, - ) - - # Send confirmation email - send_mail( - message.sender_email, - CONFERENCE_SUBMITTED, - conf_full_name=conference.name, - conf_view_url=web_url_for( - 'conference_results', - meeting=message.conference_name, - _absolute=True, - ), - fullname=message.sender_display, - user_created=user_created, - set_password_url=set_password_url, - profile_url=user.absolute_url, - node_url=node.absolute_url, - file_url=download_url, - presentation_type=message.conference_category.lower(), - is_spam=message.is_spam, - can_change_preferences=False, - logo=settings.OSF_MEETINGS_LOGO - ) - def conference_data(meeting): try: conf = Conference.objects.get(endpoint__iexact=meeting) diff --git a/website/language.py b/website/language.py index 80936924e6a..605773694d2 100644 --- a/website/language.py +++ b/website/language.py @@ -222,6 +222,9 @@ 'you should have, please contact OSF Support. ' ) +THROTTLE_PASSWORD_CHANGE_ERROR_MESSAGE = \ + 'You have recently requested to change your password. Please wait a few minutes before trying again.' + SANCTION_STATUS_MESSAGES = { 'registration': { 'approve': 'Your registration approval has been accepted.', diff --git a/website/mails/__init__.py b/website/mails/__init__.py deleted file mode 100644 index 1ed0bb2c90a..00000000000 --- a/website/mails/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .mails import * # noqa diff --git a/website/mails/listeners.py b/website/mails/listeners.py deleted file mode 100644 index 3f411d52f87..00000000000 --- a/website/mails/listeners.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Functions that listen for event signals and queue up emails. -All triggered emails live here. -""" - -from django.utils import timezone - -from website import settings -from framework.auth import signals as auth_signals -from website.project import signals as project_signals - - -@auth_signals.unconfirmed_user_created.connect -def queue_no_addon_email(user): - """Queue an email for user who has not connected an addon after - `settings.NO_ADDON_WAIT_TIME` months of signing up for the OSF. - """ - from osf.models.queued_mail import queue_mail, NO_ADDON - queue_mail( - to_addr=user.username, - mail=NO_ADDON, - send_at=timezone.now() + settings.NO_ADDON_WAIT_TIME, - user=user, - fullname=user.fullname - ) - -@project_signals.privacy_set_public.connect -def queue_first_public_project_email(user, node, meeting_creation): - """Queue and email after user has made their first - non-OSF4M project public. - """ - from osf.models.queued_mail import queue_mail, QueuedMail, NEW_PUBLIC_PROJECT_TYPE, NEW_PUBLIC_PROJECT - if not meeting_creation: - sent_mail = QueuedMail.objects.filter(user=user, email_type=NEW_PUBLIC_PROJECT_TYPE) - if not sent_mail.exists(): - queue_mail( - to_addr=user.username, - mail=NEW_PUBLIC_PROJECT, - send_at=timezone.now() + settings.NEW_PUBLIC_PROJECT_WAIT_TIME, - user=user, - nid=node._id, - fullname=user.fullname, - project_title=node.title, - osf_support_email=settings.OSF_SUPPORT_EMAIL, - ) diff --git a/website/mails/mails.py b/website/mails/mails.py deleted file mode 100644 index b98b7c37b87..00000000000 --- a/website/mails/mails.py +++ /dev/null @@ -1,650 +0,0 @@ -"""OSF mailing utilities. - -Email templates go in website/templates/emails -Templates must end in ``.txt.mako`` for plaintext emails or``.html.mako`` for html emails. - -You can then create a `Mail` object given the basename of the template and -the email subject. :: - - CONFIRM_EMAIL = Mail(tpl_prefix='confirm', subject="Confirm your email address") - -You can then use ``send_mail`` to send the email. - -Usage: :: - - from website import mails - ... - mails.send_mail('foo@bar.com', mails.CONFIRM_EMAIL, user=user) - -""" -import os -import logging -import waffle - -from mako.lookup import TemplateLookup, Template - -from framework.email import tasks -from osf import features -from website import settings -from django.core.mail import EmailMessage, get_connection - -logger = logging.getLogger(__name__) - -EMAIL_TEMPLATES_DIR = os.path.join(settings.TEMPLATES_PATH, 'emails') - -_tpl_lookup = TemplateLookup( - directories=[EMAIL_TEMPLATES_DIR], -) - -HTML_EXT = '.html.mako' - -DISABLED_MAILS = [ - 'welcome', - 'welcome_osf4i' -] - -class Mail: - """An email object. - - :param str tpl_prefix: The template name prefix. - :param str subject: The subject of the email. - :param iterable categories: Categories to add to the email using SendGrid's - SMTPAPI. Used for email analytics. - See https://sendgrid.com/docs/User_Guide/Statistics/categories.html - :param: bool engagement: Whether this is an engagement email that can be disabled with - the disable_engagement_emails waffle flag - """ - - def __init__(self, tpl_prefix, subject, categories=None, engagement=False): - self.tpl_prefix = tpl_prefix - self._subject = subject - self.categories = categories - self.engagement = engagement - - def html(self, **context): - """Render the HTML email message.""" - tpl_name = self.tpl_prefix + HTML_EXT - return render_message(tpl_name, **context) - - def subject(self, **context): - return Template(self._subject).render(**context) - - -def render_message(tpl_name, **context): - """Render an email message.""" - tpl = _tpl_lookup.get_template(tpl_name) - return tpl.render(**context) - - -def send_to_mailhog(subject, message, from_email, to_email, attachment_name=None, attachment_content=None): - email = EmailMessage( - subject=subject, - body=message, - from_email=from_email, - to=[to_email], - connection=get_connection( - backend='django.core.mail.backends.smtp.EmailBackend', - host=settings.MAILHOG_HOST, - port=settings.MAILHOG_PORT, - username='', - password='', - use_tls=False, - use_ssl=False, - ) - ) - email.content_subtype = 'html' - - if attachment_name and attachment_content: - email.attach(attachment_name, attachment_content) - - try: - email.send() - except ConnectionRefusedError: - logger.debug('Mailhog is not running. Please start it to send emails.') - return - - -def send_mail( - to_addr, - mail, - from_addr=None, - bcc_addr=None, - reply_to=None, - mailer=None, - celery=True, - username=None, - password=None, - callback=None, - attachment_name=None, - attachment_content=None, - **context): - """ - Send an email from the OSF. - Example: - from website import mails - - mails.send_email('foo@bar.com', mails.TEST, name="Foo") - - :param str to_addr: The recipient's email address - :param str bcc_addr: The BCC senders's email address (or list of addresses) - :param str reply_to: The sender's email address will appear in the reply-to header - :param Mail mail: The mail object - :param str mimetype: Either 'plain' or 'html' - :param function callback: celery task to execute after send_mail completes - :param **context: Context vars for the message template - - .. note: - Uses celery if available - """ - if waffle.switch_is_active(features.DISABLE_ENGAGEMENT_EMAILS) and mail.engagement: - return False - - from_addr = from_addr or settings.FROM_EMAIL - mailer = mailer or tasks.send_email - subject = mail.subject(**context) - message = mail.html(**context) - # Don't use ttls and login in DEBUG_MODE - ttls = login = not settings.DEBUG_MODE - logger.debug('Sending email...') - logger.debug(f'To: {to_addr}\nFrom: {from_addr}\nSubject: {subject}\nMessage: {message}') - - if waffle.switch_is_active(features.ENABLE_MAILHOG): - logger.debug('Intercepting email: sending via MailHog') - send_to_mailhog( - subject=subject, - message=message, - from_email=from_addr, - to_email=to_addr, - attachment_name=attachment_name, - attachment_content=attachment_content - ) - - kwargs = dict( - from_addr=from_addr, - to_addr=to_addr, - subject=subject, - message=message, - ttls=ttls, - login=login, - username=username, - password=password, - categories=mail.categories, - attachment_name=attachment_name, - attachment_content=attachment_content, - bcc_addr=bcc_addr, - reply_to=reply_to, - ) - - logger.debug('Preparing to send...') - if settings.USE_EMAIL: - if settings.USE_CELERY and celery: - logger.debug('Sending via celery...') - return mailer.apply_async(kwargs=kwargs, link=callback) - else: - logger.debug('Sending without celery') - ret = mailer(**kwargs) - if callback: - callback() - - return ret - - -def get_english_article(word): - """ - Decide whether to use 'a' or 'an' for a given English word. - - :param word: the word immediately after the article - :return: 'a' or 'an' - """ - return 'a' + ('n' if word[0].lower() in 'aeiou' else '') - - -# Predefined Emails - -TEST = Mail('test', subject='A test email to ${name}', categories=['test']) - -# Emails for first-time login through external identity providers. -EXTERNAL_LOGIN_CONFIRM_EMAIL_CREATE = Mail( - 'external_confirm_create', - subject='OSF Account Verification' -) - -FORK_COMPLETED = Mail( - 'fork_completed', - subject='Your fork has completed' -) - -FORK_FAILED = Mail( - 'fork_failed', - subject='Your fork has failed' -) - -EXTERNAL_LOGIN_CONFIRM_EMAIL_LINK = Mail( - 'external_confirm_link', - subject='OSF Account Verification' -) -EXTERNAL_LOGIN_LINK_SUCCESS = Mail( - 'external_confirm_success', - subject='OSF Account Verification Success' -) - -# Sign up confirmation emails for OSF, native campaigns and branded campaigns -INITIAL_CONFIRM_EMAIL = Mail( - 'initial_confirm', - subject='OSF Account Verification' -) -CONFIRM_EMAIL = Mail( - 'confirm', - subject='Add a new email to your OSF account' -) -CONFIRM_EMAIL_ERPC = Mail( - 'confirm_erpc', - subject='OSF Account Verification, Election Research Preacceptance Competition' -) -CONFIRM_EMAIL_AGU_CONFERENCE_2023 = Mail( - 'confirm_agu_conference_2023', - subject='OSF Account Verification, from the American Geophysical Union Conference' -) -CONFIRM_EMAIL_AGU_CONFERENCE = Mail( - 'confirm_agu_conference', - subject='OSF Account Verification, from the American Geophysical Union Conference' -) -CONFIRM_EMAIL_PREPRINTS = lambda name, provider: Mail( - f'confirm_preprints_{name}', - subject=f'OSF Account Verification, {provider}' -) -CONFIRM_EMAIL_REGISTRIES_OSF = Mail( - 'confirm_registries_osf', - subject='OSF Account Verification, OSF Registries' -) -CONFIRM_EMAIL_MODERATION = lambda provider: Mail( - 'confirm_moderation', - subject=f'OSF Account Verification, {provider.name}' -) - -# Merge account, add or remove email confirmation emails. -CONFIRM_MERGE = Mail('confirm_merge', subject='Confirm account merge') -COLLECTION_SUBMISSION_REJECTED = lambda collection, node: Mail( - 'collection_submission_rejected', - subject=f'{node.title} was not accepted into {collection.title}' -) -COLLECTION_SUBMISSION_SUBMITTED = lambda submitter, node: Mail( - 'collection_submission_submitted', - subject=f'{submitter.fullname} has requested to add {node.title} to a collection' -) -COLLECTION_SUBMISSION_ACCEPTED = lambda collection, node: Mail( - 'collection_submission_accepted', - subject=f'{node.title} was accepted into {collection.title}' -) -COLLECTION_SUBMISSION_REMOVED_MODERATOR = lambda collection, node: Mail( - 'collection_submission_removed_moderator', - subject=f'{node.title} was removed from {collection.title}' -) -COLLECTION_SUBMISSION_REMOVED_ADMIN = lambda collection, node: Mail( - 'collection_submission_removed_admin', - subject=f'{node.title} was removed from {collection.title}' -) -COLLECTION_SUBMISSION_REMOVED_PRIVATE = lambda collection, node: Mail( - 'collection_submission_removed_private', - subject=f'{node.title} was removed from {collection.title}' -) -COLLECTION_SUBMISSION_CANCEL = lambda collection, node: Mail( - 'collection_submission_cancel', - subject=f'Request to add {node.title} to {collection.title} was canceled' -) - -PRIMARY_EMAIL_CHANGED = Mail('primary_email_changed', subject='Primary email changed') - - -# Contributor added confirmation emails -INVITE_DEFAULT = Mail( - 'invite_default', - subject='You have been added as a contributor to an OSF project.' -) -INVITE_OSF_PREPRINT = Mail( - 'invite_preprints_osf', - subject='You have been added as a contributor to an OSF preprint.' -) -INVITE_PREPRINT = lambda provider: Mail( - 'invite_preprints', - subject=f'You have been added as a contributor to {get_english_article(provider.name)} {provider.name} {provider.preprint_word}.' -) -INVITE_DRAFT_REGISTRATION = Mail( - 'invite_draft_registration', - subject='You have a new registration draft' -) -CONTRIBUTOR_ADDED_DEFAULT = Mail( - 'contributor_added_default', - subject='You have been added as a contributor to an OSF project.' -) -CONTRIBUTOR_ADDED_OSF_PREPRINT = Mail( - 'contributor_added_preprints_osf', - subject='You have been added as a contributor to an OSF preprint.' -) -CONTRIBUTOR_ADDED_PREPRINT = lambda provider: Mail( - 'contributor_added_preprints', - subject=f'You have been added as a contributor to {get_english_article(provider.name)} {provider.name} {provider.preprint_word}.' -) -CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF = Mail( - 'contributor_added_preprint_node_from_osf', - subject='You have been added as a contributor to an OSF project.' -) -CONTRIBUTOR_ADDED_DRAFT_REGISTRATION = Mail( - 'contributor_added_draft_registration', - subject='You have a new registration draft.' -) -MODERATOR_ADDED = lambda provider: Mail( - 'moderator_added', - subject=f'You have been added as a moderator for {provider.name}' -) -CONTRIBUTOR_ADDED_ACCESS_REQUEST = Mail( - 'contributor_added_access_request', - subject='Your access request to an OSF project has been approved' -) -FORWARD_INVITE = Mail('forward_invite', subject='Please forward to ${fullname}') -FORWARD_INVITE_REGISTERED = Mail('forward_invite_registered', subject='Please forward to ${fullname}') - -FORGOT_PASSWORD = Mail('forgot_password', subject='Reset Password') -FORGOT_PASSWORD_INSTITUTION = Mail('forgot_password_institution', subject='Set Password') -PASSWORD_RESET = Mail('password_reset', subject='Your OSF password has been reset') -PENDING_VERIFICATION = Mail('pending_invite', subject='Your account is almost ready!') -PENDING_VERIFICATION_REGISTERED = Mail('pending_registered', subject='Received request to be a contributor') - -REQUEST_EXPORT = Mail('support_request', subject='[via OSF] Export Request') -REQUEST_DEACTIVATION = Mail('support_request', subject='[via OSF] Deactivation Request') - -REQUEST_DEACTIVATION_COMPLETE = Mail('request_deactivation_complete', subject='[via OSF] OSF account deactivated') - -SPAM_USER_BANNED = Mail('spam_user_banned', subject='[OSF] Account flagged as spam') -SPAM_FILES_DETECTED = Mail( - 'spam_files_detected', - subject='[auto] Spam files audit' -) - -CONFERENCE_SUBMITTED = Mail( - 'conference_submitted', - subject='Project created on OSF', -) -CONFERENCE_INACTIVE = Mail( - 'conference_inactive', - subject='OSF Error: Conference inactive', -) -CONFERENCE_FAILED = Mail( - 'conference_failed', - subject='OSF Error: No files attached', -) -CONFERENCE_DEPRECATION = Mail( - 'conference_deprecation', - subject='Meeting Service Discontinued', -) - -DIGEST = Mail( - 'digest', subject='OSF Notifications', - categories=['notifications', 'notifications-digest'] -) - -DIGEST_REVIEWS_MODERATORS = Mail( - 'digest_reviews_moderators', - subject='Recent submissions to ${provider_name}', -) - -TRANSACTIONAL = Mail( - 'transactional', subject='OSF: ${subject}', - categories=['notifications', 'notifications-transactional'] -) - -# Retraction related Mail objects -PENDING_RETRACTION_ADMIN = Mail( - 'pending_retraction_admin', - subject='Withdrawal pending for one of your registrations.' -) -PENDING_RETRACTION_NON_ADMIN = Mail( - 'pending_retraction_non_admin', - subject='Withdrawal pending for one of your registrations.' -) -PENDING_RETRACTION_NON_ADMIN = Mail( - 'pending_retraction_non_admin', - subject='Withdrawal pending for one of your projects.' -) -# Embargo related Mail objects -PENDING_EMBARGO_ADMIN = Mail( - 'pending_embargo_admin', - subject='Admin decision pending for one of your registrations.' -) -PENDING_EMBARGO_NON_ADMIN = Mail( - 'pending_embargo_non_admin', - subject='Admin decision pending for one of your registrations.' -) -# Registration related Mail Objects -PENDING_REGISTRATION_ADMIN = Mail( - 'pending_registration_admin', - subject='Admin decision pending for one of your registrations.' -) -PENDING_REGISTRATION_NON_ADMIN = Mail( - 'pending_registration_non_admin', - subject='Admin decision pending for one of your registrations.' -) -PENDING_EMBARGO_TERMINATION_ADMIN = Mail( - 'pending_embargo_termination_admin', - subject='Request to end an embargo early for one of your registrations.' -) -PENDING_EMBARGO_TERMINATION_NON_ADMIN = Mail( - 'pending_embargo_termination_non_admin', - subject='Request to end an embargo early for one of your projects.' -) - -FILE_OPERATION_SUCCESS = Mail( - 'file_operation_success', - subject='Your ${action} has finished', -) -FILE_OPERATION_FAILED = Mail( - 'file_operation_failed', - subject='Your ${action} has failed', -) - -UNESCAPE = '<% from osf.utils.sanitize import unescape_entities %> ${unescape_entities(src.title)}' -PROBLEM_REGISTERING = 'Problem registering ' + UNESCAPE - -ARCHIVE_SIZE_EXCEEDED_DESK = Mail( - 'archive_size_exceeded_desk', - subject=PROBLEM_REGISTERING -) -ARCHIVE_SIZE_EXCEEDED_USER = Mail( - 'archive_size_exceeded_user', - subject=PROBLEM_REGISTERING -) - -ARCHIVE_COPY_ERROR_DESK = Mail( - 'archive_copy_error_desk', - subject=PROBLEM_REGISTERING -) -ARCHIVE_COPY_ERROR_USER = Mail( - 'archive_copy_error_user', - subject=PROBLEM_REGISTERING - -) -ARCHIVE_FILE_NOT_FOUND_DESK = Mail( - 'archive_file_not_found_desk', - subject=PROBLEM_REGISTERING -) -ARCHIVE_FILE_NOT_FOUND_USER = Mail( - 'archive_file_not_found_user', - subject='Registration failed because of altered files' -) - -ARCHIVE_UNCAUGHT_ERROR_DESK = Mail( - 'archive_uncaught_error_desk', - subject=PROBLEM_REGISTERING -) - -ARCHIVE_REGISTRATION_STUCK_DESK = Mail( - 'archive_registration_stuck_desk', - subject='[auto] Stuck registrations audit' -) - -ARCHIVE_UNCAUGHT_ERROR_USER = Mail( - 'archive_uncaught_error_user', - subject=PROBLEM_REGISTERING -) - -ARCHIVE_SUCCESS = Mail( - 'archive_success', - subject='Registration of ' + UNESCAPE + ' complete' -) - -WELCOME = Mail( - 'welcome', - subject='Welcome to OSF', - engagement=True -) - -WELCOME_OSF4I = Mail( - 'welcome_osf4i', - subject='Welcome to OSF', - engagement=True -) - -DUPLICATE_ACCOUNTS_OSF4I = Mail( - 'duplicate_accounts_sso_osf4i', - subject='Duplicate OSF Accounts' -) - -ADD_SSO_EMAIL_OSF4I = Mail( - 'add_sso_email_osf4i', - subject='Your OSF Account Email Address' -) - -EMPTY = Mail('empty', subject='${subject}') - -REVIEWS_SUBMISSION_CONFIRMATION = Mail( - 'reviews_submission_confirmation', - subject='Confirmation of your submission to ${provider_name}' -) - -REVIEWS_RESUBMISSION_CONFIRMATION = Mail( - 'reviews_resubmission_confirmation', - subject='Confirmation of your submission to ${provider_name}' -) - -ACCESS_REQUEST_SUBMITTED = Mail( - 'access_request_submitted', - subject='An OSF user has requested access to your ${node.project_or_component}' -) - -ACCESS_REQUEST_DENIED = Mail( - 'access_request_rejected', - subject='Your access request to an OSF project has been declined' -) - -CROSSREF_ERROR = Mail( - 'crossref_doi_error', - subject='There was an error creating a DOI for preprint(s). batch_id: ${batch_id}' -) - -CROSSREF_DOIS_PENDING = Mail( - 'crossref_doi_pending', - subject='There are ${pending_doi_count} preprints with crossref DOI pending.' -) - -WITHDRAWAL_REQUEST_GRANTED = Mail( - 'withdrawal_request_granted', - subject='Your ${document_type} has been withdrawn', -) - -WITHDRAWAL_REQUEST_DECLINED = Mail( - 'withdrawal_request_declined', - subject='Your withdrawal request has been declined', -) - -TOU_NOTIF = Mail( - 'tou_notif', - subject='Updated Terms of Use for COS Websites and Services', -) - -STORAGE_CAP_EXCEEDED_ANNOUNCEMENT = Mail( - 'storage_cap_exceeded_announcement', - subject='Action Required to avoid disruption to your OSF project', -) - -INSTITUTION_DEACTIVATION = Mail( - 'institution_deactivation', - subject='Your OSF login has changed - here\'s what you need to know!' -) - -REGISTRATION_BULK_UPLOAD_PRODUCT_OWNER = Mail( - 'registration_bulk_upload_product_owner', - subject='Registry Could Not Bulk Upload Registrations' -) - -REGISTRATION_BULK_UPLOAD_SUCCESS_ALL = Mail( - 'registration_bulk_upload_success_all', - subject='Registrations Successfully Bulk Uploaded to your Community\'s Registry' -) - -REGISTRATION_BULK_UPLOAD_SUCCESS_PARTIAL = Mail( - 'registration_bulk_upload_success_partial', - subject='Some Registrations Successfully Bulk Uploaded to your Community\'s Registry' -) - -REGISTRATION_BULK_UPLOAD_FAILURE_ALL = Mail( - 'registration_bulk_upload_failure_all', - subject='Registrations Were Not Bulk Uploaded to your Community\'s Registry' -) - -REGISTRATION_BULK_UPLOAD_FAILURE_DUPLICATES = Mail( - 'registration_bulk_upload_failure_duplicates', - subject='Registrations Were Not Bulk Uploaded to your Community\'s Registry' -) - -REGISTRATION_BULK_UPLOAD_UNEXPECTED_FAILURE = Mail( - 'registration_bulk_upload_unexpected_failure', - subject='Registrations Were Not Bulk Uploaded to your Community\'s Registry' -) - -SCHEMA_RESPONSE_INITIATED = Mail( - 'updates_initiated', - subject='Updates for ${resource_type} ${title} are in progress' -) - - -SCHEMA_RESPONSE_SUBMITTED = Mail( - 'updates_pending_approval', - subject='Updates for ${resource_type} ${title} are pending Admin approval' -) - - -SCHEMA_RESPONSE_APPROVED = Mail( - 'updates_approved', - subject='The updates for ${resource_type} ${title} have been approved' -) - - -SCHEMA_RESPONSE_REJECTED = Mail( - 'updates_rejected', - subject='The updates for ${resource_type} ${title} were not accepted' -) - -ADDONS_BOA_JOB_COMPLETE = Mail( - 'addons_boa_job_complete', - subject='Your Boa job has completed' -) - -ADDONS_BOA_JOB_FAILURE = Mail( - 'addons_boa_job_failure', - subject='Your Boa job has failed' -) - -NODE_REQUEST_INSTITUTIONAL_ACCESS_REQUEST = Mail( - 'node_request_institutional_access_request', - subject='Institutional Access Project Request' -) - -USER_MESSAGE_INSTITUTIONAL_ACCESS_REQUEST = Mail( - 'user_message_institutional_access_request', - subject='Message from Institutional Admin' -) - -PROJECT_AFFILIATION_CHANGED = Mail( - 'project_affiliation_changed', - subject='Project Affiliation Changed' -) diff --git a/website/mails/presends.py b/website/mails/presends.py deleted file mode 100644 index 3a3175c99ee..00000000000 --- a/website/mails/presends.py +++ /dev/null @@ -1,55 +0,0 @@ -from django.utils import timezone - -from website import settings - -def no_addon(email): - return len([addon for addon in email.user.get_addons() if addon.config.short_name != 'osfstorage']) == 0 - -def no_login(email): - from osf.models.queued_mail import QueuedMail, NO_LOGIN_TYPE - sent = QueuedMail.objects.filter(user=email.user, email_type=NO_LOGIN_TYPE).exclude(_id=email._id) - if sent.exists(): - return False - return email.user.date_last_login < timezone.now() - settings.NO_LOGIN_WAIT_TIME - -def new_public_project(email): - """ Will check to make sure the project that triggered this presend is still public - before sending the email. It also checks to make sure this is the first (and only) - new public project email to be sent - - :param email: QueuedMail object, with 'nid' in its data field - :return: boolean based on whether the email should be sent - """ - - # In line import to prevent circular importing - from osf.models import AbstractNode - - node = AbstractNode.load(email.data['nid']) - - if not node: - return False - public = email.find_sent_of_same_type_and_user() - return node.is_public and not len(public) - - -def welcome_osf4m(email): - """ presend has two functions. First is to make sure that the user has not - converted to a regular OSF user by logging in. Second is to populate the - data field with downloads by finding the file/project (node_settings) and - counting downloads of all files within that project - - :param email: QueuedMail object with data field including fid - :return: boolean based on whether the email should be sent - """ - # In line import to prevent circular importing - from addons.osfstorage.models import OsfStorageFileNode - if email.user.date_last_login: - if email.user.date_last_login > timezone.now() - settings.WELCOME_OSF4M_WAIT_TIME_GRACE: - return False - upload = OsfStorageFileNode.load(email.data['fid']) - if upload: - email.data['downloads'] = upload.get_download_count() - else: - email.data['downloads'] = 0 - email.save() - return True diff --git a/website/maintenance.py b/website/maintenance.py index 98359540cfb..2424651d758 100644 --- a/website/maintenance.py +++ b/website/maintenance.py @@ -42,11 +42,19 @@ def set_maintenance(message, level=1, start=None, end=None): return {'start': state.start, 'end': state.end} + +class InFailedSqlTransaction: + pass + + def get_maintenance(): """Get the current start and end times for the maintenance state. Return None if there is no current maintenance state. """ - maintenance = MaintenanceState.objects.all().first() + try: + maintenance = MaintenanceState.objects.all().first() + except InFailedSqlTransaction: + return None return MaintenanceStateSerializer(maintenance).data if maintenance else None def unset_maintenance(): diff --git a/website/notifications/emails.py b/website/notifications/emails.py deleted file mode 100644 index d26d43351d5..00000000000 --- a/website/notifications/emails.py +++ /dev/null @@ -1,243 +0,0 @@ -from django.apps import apps - -from babel import dates, core, Locale - -from osf.models import AbstractNode, NotificationDigest, NotificationSubscription -from osf.utils.permissions import ADMIN, READ -from website import mails -from website.notifications import constants -from website.notifications import utils -from website.util import web_url_for - - -def notify(event, user, node, timestamp, **context): - """Retrieve appropriate ***subscription*** and passe user list - - :param event: event that triggered the notification - :param user: user who triggered notification - :param node: instance of Node - :param timestamp: time event happened - :param context: optional variables specific to templates - target_user: used with comment_replies - :return: List of user ids notifications were sent to - """ - sent_users = [] - # The user who the current comment is a reply to - target_user = context.get('target_user', None) - exclude = context.get('exclude', []) - # do not notify user who initiated the emails - exclude.append(user._id) - - event_type = utils.find_subscription_type(event) - if target_user and event_type in constants.USER_SUBSCRIPTIONS_AVAILABLE: - # global user - subscriptions = get_user_subscriptions(target_user, event_type) - else: - # local project user - subscriptions = compile_subscriptions(node, event_type, event) - - for notification_type in subscriptions: - if notification_type == 'none' or not subscriptions[notification_type]: - continue - # Remove excluded ids from each notification type - subscriptions[notification_type] = [guid for guid in subscriptions[notification_type] if guid not in exclude] - - # If target, they get a reply email and are removed from the general email - if target_user and target_user._id in subscriptions[notification_type]: - subscriptions[notification_type].remove(target_user._id) - store_emails([target_user._id], notification_type, 'comment_replies', user, node, timestamp, **context) - sent_users.append(target_user._id) - - if subscriptions[notification_type]: - store_emails(subscriptions[notification_type], notification_type, event_type, user, node, timestamp, **context) - sent_users.extend(subscriptions[notification_type]) - return sent_users - -def notify_mentions(event, user, node, timestamp, **context): - OSFUser = apps.get_model('osf', 'OSFUser') - recipient_ids = context.get('new_mentions', []) - recipients = OSFUser.objects.filter(guids___id__in=recipient_ids) - sent_users = notify_global_event(event, user, node, timestamp, recipients, context=context) - return sent_users - -def notify_global_event(event, sender_user, node, timestamp, recipients, template=None, context=None): - event_type = utils.find_subscription_type(event) - sent_users = [] - if not context: - context = {} - - for recipient in recipients: - subscriptions = get_user_subscriptions(recipient, event_type) - context['is_creator'] = recipient == node.creator - if node.provider: - context['has_psyarxiv_chronos_text'] = node.has_permission(recipient, ADMIN) and 'psyarxiv' in node.provider.name.lower() - for notification_type in subscriptions: - if (notification_type != 'none' and subscriptions[notification_type] and recipient._id in subscriptions[notification_type]): - store_emails([recipient._id], notification_type, event, sender_user, node, timestamp, template=template, **context) - sent_users.append(recipient._id) - - return sent_users - - -def store_emails(recipient_ids, notification_type, event, user, node, timestamp, abstract_provider=None, template=None, **context): - """Store notification emails - - Emails are sent via celery beat as digests - :param recipient_ids: List of user ids to send mail to. - :param notification_type: from constants.Notification_types - :param event: event that triggered notification - :param user: user who triggered the notification - :param node: instance of Node - :param timestamp: time event happened - :param context: - :return: -- - """ - OSFUser = apps.get_model('osf', 'OSFUser') - - if notification_type == 'none': - return - - # If `template` is not specified, default to using a template with name `event` - template = f'{template or event}.html.mako' - - # user whose action triggered email sending - context['user'] = user - node_lineage_ids = get_node_lineage(node) if node else [] - - for recipient_id in recipient_ids: - if recipient_id == user._id: - continue - recipient = OSFUser.load(recipient_id) - if recipient.is_disabled: - continue - context['localized_timestamp'] = localize_timestamp(timestamp, recipient) - context['recipient'] = recipient - message = mails.render_message(template, **context) - digest = NotificationDigest( - timestamp=timestamp, - send_type=notification_type, - event=event, - user=recipient, - message=message, - node_lineage=node_lineage_ids, - provider=abstract_provider - ) - digest.save() - - -def compile_subscriptions(node, event_type, event=None, level=0): - """Recurse through node and parents for subscriptions. - - :param node: current node - :param event_type: Generally node_subscriptions_available - :param event: Particular event such a file_updated that has specific file subs - :param level: How deep the recursion is - :return: a dict of notification types with lists of users. - """ - subscriptions = check_node(node, event_type) - if event: - subscriptions = check_node(node, event) # Gets particular event subscriptions - parent_subscriptions = compile_subscriptions(node, event_type, level=level + 1) # get node and parent subs - elif getattr(node, 'parent_id', False): - parent_subscriptions = \ - compile_subscriptions(AbstractNode.load(node.parent_id), event_type, level=level + 1) - else: - parent_subscriptions = check_node(None, event_type) - for notification_type in parent_subscriptions: - p_sub_n = parent_subscriptions[notification_type] - p_sub_n.extend(subscriptions[notification_type]) - for nt in subscriptions: - if notification_type != nt: - p_sub_n = list(set(p_sub_n).difference(set(subscriptions[nt]))) - if level == 0: - p_sub_n, removed = utils.separate_users(node, p_sub_n) - parent_subscriptions[notification_type] = p_sub_n - return parent_subscriptions - - -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)) - for notification_type in node_subscriptions: - users = getattr(subscription, notification_type, []) - if users: - for user in users.exclude(date_disabled__isnull=False): - if node.has_permission(user, READ): - node_subscriptions[notification_type].append(user._id) - return node_subscriptions - - -def get_user_subscriptions(user, event): - if user.is_disabled: - return {} - user_subscription = NotificationSubscription.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: - return {key: [user._id] if (event in constants.USER_SUBSCRIPTIONS_AVAILABLE and key == 'email_transactional') else [] for key in constants.NOTIFICATION_TYPES} - - -def get_node_lineage(node): - """ Get a list of node ids in order from the node to top most project - e.g. [parent._id, node._id] - """ - from osf.models import Preprint - lineage = [node._id] - if isinstance(node, Preprint): - return lineage - - while node.parent_id: - node = node.parent_node - lineage = [node._id] + lineage - - return lineage - - -def get_settings_url(uid, user): - if uid == user._id: - return web_url_for('user_notifications', _absolute=True) - - node = AbstractNode.load(uid) - assert node, 'get_settings_url recieved an invalid Node id' - return node.web_url_for('node_setting', _guid=True, _absolute=True) - -def fix_locale(locale): - """Atempt to fix a locale to have the correct casing, e.g. de_de -> de_DE - - This is NOT guaranteed to return a valid locale identifier. - """ - try: - language, territory = locale.split('_', 1) - except ValueError: - return locale - else: - return '_'.join([language, territory.upper()]) - -def localize_timestamp(timestamp, user): - try: - user_timezone = dates.get_timezone(user.timezone) - except LookupError: - user_timezone = dates.get_timezone('Etc/UTC') - - try: - user_locale = Locale(user.locale) - except core.UnknownLocaleError: - user_locale = Locale('en') - - # Do our best to find a valid locale - try: - user_locale.date_formats - except OSError: # An IOError will be raised if locale's casing is incorrect, e.g. de_de vs. de_DE - # Attempt to fix the locale, e.g. de_de -> de_DE - try: - user_locale = Locale(fix_locale(user.locale)) - user_locale.date_formats - except (core.UnknownLocaleError, OSError): - user_locale = Locale('en') - - formatted_date = dates.format_date(timestamp, format='full', locale=user_locale) - formatted_time = dates.format_time(timestamp, format='short', tzinfo=user_timezone, locale=user_locale) - - return f'{formatted_time} on {formatted_date}' diff --git a/website/notifications/events/__init__.py b/website/notifications/events/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/website/notifications/events/base.py b/website/notifications/events/base.py deleted file mode 100644 index 7378c8ced43..00000000000 --- a/website/notifications/events/base.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Basic Event handling for events that need subscriptions""" - -from django.utils import timezone - -from website.notifications import emails - - -event_registry = {} - - -def register(event_type): - """Register classes into event_registry""" - def decorator(cls): - event_registry[event_type] = cls - return cls - return decorator - - -class Event: - """Base event class for notification. - - - abstract methods set methods that should be defined by subclasses. - To use this interface you must use the class as a Super (inherited). - - Implement property methods in subclasses - """ - def __init__(self, user, node, action): - self.user = user - self.profile_image_url = user.profile_image_url() - self.node = node - self.action = action - self.timestamp = timezone.now() - - def perform(self): - """Call emails.notify to notify users of an action""" - emails.notify( - event=self.event_type, - user=self.user, - node=self.node, - timestamp=self.timestamp, - message=self.html_message, - profile_image_url=self.profile_image_url, - url=self.url - ) - - @property - def text_message(self): - """String: build a plain text message.""" - raise NotImplementedError - - @property - def html_message(self): - """String: build an html message.""" - raise NotImplementedError - - @property - def url(self): - """String: build a url for the message.""" - raise NotImplementedError - - @property - def event_type(self): - """String - - Examples: - _file_updated""" - raise NotImplementedError - - -class RegistryError(TypeError): - pass diff --git a/website/notifications/events/files.py b/website/notifications/events/files.py deleted file mode 100644 index fdaabad0426..00000000000 --- a/website/notifications/events/files.py +++ /dev/null @@ -1,319 +0,0 @@ -"""File event module - -These classes are registered in event_registry and are callable through the - register. The main way these are used is with the signals blinker module - that catches the signal with the file_updated function. - -FileEvent and ComplexFileEvent are parent classes with shared functionality. -""" -from furl import furl -import markupsafe - -from website.notifications import emails -from website.notifications.constants import NOTIFICATION_TYPES -from website.notifications import utils -from website.notifications.events.base import ( - register, - Event, - event_registry, - RegistryError, -) -from website.notifications.events import utils as event_utils -from osf.models import AbstractNode, NodeLog, Preprint -from addons.base.signals import file_updated as signal - - -@signal.connect -def file_updated(self, target=None, user=None, event_type=None, payload=None): - if isinstance(target, Preprint): - return - if event_type not in event_registry: - raise RegistryError - event = event_registry[event_type](user, target, event_type, payload=payload) - event.perform() - - -class FileEvent(Event): - """File event base class, should not be called directly""" - - def __init__(self, user, node, event, payload=None): - super().__init__(user, node, event) - self.payload = payload - self._url = None - - @property - def html_message(self): - """Most basic html message""" - f_type, action = self.action.split('_') - if self.payload['metadata']['materialized'].endswith('/'): - f_type = 'folder' - return '{action} {f_type} "{name}".'.format( - action=markupsafe.escape(action), - f_type=markupsafe.escape(f_type), - name=markupsafe.escape(self.payload['metadata']['materialized'].lstrip('/')) - ) - - @property - def text_message(self): - """Most basic message without html tags. For future use.""" - f_type, action = self.action.split('_') - if self.payload['metadata']['materialized'].endswith('/'): - f_type = 'folder' - return '{action} {f_type} "{name}".'.format( - action=action, - f_type=f_type, - name=self.payload['metadata']['materialized'].lstrip('/') - ) - - @property - def event_type(self): - """Most basic event type.""" - return 'file_updated' - - @property - def waterbutler_id(self): - """Waterbutler's file id for the file in question.""" - return self.payload['metadata']['path'].strip('/') - - @property - def url(self): - """Basis of making urls, this returns the url to the node.""" - if self._url is None: - # NOTE: furl encoding to be verified later - self._url = furl( - self.node.absolute_url, - path=self.node.web_url_for('collect_file_trees').split('/') - ) - - return self._url.url - - -@register(NodeLog.FILE_ADDED) -class FileAdded(FileEvent): - """Actual class called when a file is added""" - - @property - def event_type(self): - return f'{self.waterbutler_id}_file_updated' - - -@register(NodeLog.FILE_UPDATED) -class FileUpdated(FileEvent): - """Actual class called when a file is updated""" - - @property - def event_type(self): - return f'{self.waterbutler_id}_file_updated' - - -@register(NodeLog.FILE_REMOVED) -class FileRemoved(FileEvent): - """Actual class called when a file is removed""" - pass - - -@register(NodeLog.FOLDER_CREATED) -class FolderCreated(FileEvent): - """Actual class called when a folder is created""" - pass - - -class ComplexFileEvent(FileEvent): - """ Parent class for move and copy files.""" - def __init__(self, user, node, event, payload=None): - super().__init__(user, node, event, payload=payload) - - source_nid = self.payload['source']['node']['_id'] - self.source_node = AbstractNode.load(source_nid) or Preprint.load(source_nid) - self.addon = self.node.get_addon(self.payload['destination']['provider']) - - def _build_message(self, html=False): - addon, f_type, action = tuple(self.action.split('_')) - # f_type is always file for the action - if self.payload['destination']['kind'] == 'folder': - f_type = 'folder' - - destination_name = self.payload['destination']['materialized'].lstrip('/') - source_name = self.payload['source']['materialized'].lstrip('/') - - if html: - return ( - '{action} {f_type} "{source_name}" ' - 'from {source_addon} in {source_node_title} ' - 'to "{dest_name}" in {dest_addon} in {dest_node_title}.' - ).format( - action=markupsafe.escape(action), - f_type=markupsafe.escape(f_type), - source_name=markupsafe.escape(source_name), - source_addon=markupsafe.escape(self.payload['source']['addon']), - source_node_title=markupsafe.escape(self.payload['source']['node']['title']), - dest_name=markupsafe.escape(destination_name), - dest_addon=markupsafe.escape(self.payload['destination']['addon']), - dest_node_title=markupsafe.escape(self.payload['destination']['node']['title']), - ) - return ( - '{action} {f_type} "{source_name}" ' - 'from {source_addon} in {source_node_title} ' - 'to "{dest_name}" in {dest_addon} in {dest_node_title}.' - ).format( - action=action, - f_type=f_type, - source_name=source_name, - source_addon=self.payload['source']['addon'], - source_node_title=self.payload['source']['node']['title'], - dest_name=destination_name, - dest_addon=self.payload['destination']['addon'], - dest_node_title=self.payload['destination']['node']['title'], - ) - - @property - def html_message(self): - return self._build_message(html=True) - - @property - def text_message(self): - return self._build_message(html=False) - - @property - def waterbutler_id(self): - return self.payload['destination']['path'].strip('/') - - @property - def event_type(self): - if self.payload['destination']['kind'] != 'folder': - return f'{self.waterbutler_id}_file_updated' # file - - return 'file_updated' # folder - - @property - def source_url(self): - # NOTE: furl encoding to be verified later - url = furl( - self.source_node.absolute_url, - path=self.source_node.web_url_for('collect_file_trees').split('/') - ) - return url.url - - -@register(NodeLog.FILE_RENAMED) -class AddonFileRenamed(ComplexFileEvent): - """Actual class called when a file is renamed.""" - - @property - def html_message(self): - return 'renamed {kind} "{source_name}" to "{destination_name}".'.format( - kind=markupsafe.escape(self.payload['destination']['kind']), - source_name=markupsafe.escape(self.payload['source']['materialized']), - destination_name=markupsafe.escape(self.payload['destination']['materialized']), - ) - - @property - def text_message(self): - return 'renamed {kind} "{source_name}" to "{destination_name}".'.format( - kind=self.payload['destination']['kind'], - source_name=self.payload['source']['materialized'], - destination_name=self.payload['destination']['materialized'], - ) - - -@register(NodeLog.FILE_MOVED) -class AddonFileMoved(ComplexFileEvent): - """Actual class called when a file is moved.""" - - def perform(self): - """Format and send messages to different user groups. - - Users fall into three categories: moved, warned, and removed - - Moved users are users with subscriptions on the new node. - - Warned users are users without subscriptions on the new node, but - they do have permissions - - Removed users are told that they do not have permissions on the - new node and their subscription has been removed. - This will be **much** more useful when individual files have their - own subscription. - """ - # Do this is the two nodes are the same, no one needs to know specifics of permissions - if self.node == self.source_node: - super().perform() - return - # File - if self.payload['destination']['kind'] != 'folder': - moved, warn, rm_users = event_utils.categorize_users(self.user, self.event_type, self.source_node, - self.event_type, self.node) - warn_message = f'{self.html_message} You are no longer tracking that file based on the settings you selected for the component.' - remove_message = ( - f'{self.html_message} Your subscription has been removed due to ' - 'insufficient permissions in the new component.' - ) - # Folder - else: - # Gets all the files in a folder to look for permissions conflicts - files = event_utils.get_file_subs_from_folder(self.addon, self.user, self.payload['destination']['kind'], - self.payload['destination']['path'], - self.payload['destination']['name']) - # Bins users into different permissions - moved, warn, rm_users = event_utils.compile_user_lists(files, self.user, self.source_node, self.node) - - # For users that don't have individual file subscription but has permission on the new node - warn_message = f'{self.html_message} You are no longer tracking that folder or files within based on the settings you selected for the component.' - # For users without permission on the new node - remove_message = ( - f'{self.html_message} Your subscription has been removed for the ' - 'folder, or a file within, due to insufficient permissions in the new ' - 'component.' - ) - - # Move the document from one subscription to another because the old one isn't needed - utils.move_subscription(rm_users, self.event_type, self.source_node, self.event_type, self.node) - # Notify each user - for notification in NOTIFICATION_TYPES: - if notification == 'none': - continue - if moved[notification]: - emails.store_emails(moved[notification], notification, 'file_updated', self.user, self.node, - self.timestamp, message=self.html_message, - profile_image_url=self.profile_image_url, url=self.url) - if warn[notification]: - emails.store_emails(warn[notification], notification, 'file_updated', self.user, self.node, - self.timestamp, message=warn_message, profile_image_url=self.profile_image_url, - url=self.url) - if rm_users[notification]: - emails.store_emails(rm_users[notification], notification, 'file_updated', self.user, self.source_node, - self.timestamp, message=remove_message, - profile_image_url=self.profile_image_url, url=self.source_url) - - -@register(NodeLog.FILE_COPIED) -class AddonFileCopied(ComplexFileEvent): - """Actual class called when a file is copied""" - def perform(self): - """Format and send messages to different user groups. - - This is similar to the FileMoved perform method. The main - difference is the moved and earned user groups are added - together because they both don't have a subscription to a - newly copied file. - """ - remove_message = self.html_message + ' You do not have permission in the new component.' - if self.node == self.source_node: - super().perform() - return - if self.payload['destination']['kind'] != 'folder': - moved, warn, rm_users = event_utils.categorize_users(self.user, self.event_type, self.source_node, - self.event_type, self.node) - else: - files = event_utils.get_file_subs_from_folder(self.addon, self.user, self.payload['destination']['kind'], - self.payload['destination']['path'], - self.payload['destination']['name']) - moved, warn, rm_users = event_utils.compile_user_lists(files, self.user, self.source_node, self.node) - for notification in NOTIFICATION_TYPES: - if notification == 'none': - continue - if moved[notification] or warn[notification]: - users = list(set(moved[notification]).union(set(warn[notification]))) - emails.store_emails(users, notification, 'file_updated', self.user, self.node, self.timestamp, - message=self.html_message, profile_image_url=self.profile_image_url, url=self.url) - if rm_users[notification]: - emails.store_emails(rm_users[notification], notification, 'file_updated', self.user, self.source_node, - self.timestamp, message=remove_message, - profile_image_url=self.profile_image_url, url=self.source_url) diff --git a/website/notifications/events/utils.py b/website/notifications/events/utils.py deleted file mode 100644 index 83e4c79bce4..00000000000 --- a/website/notifications/events/utils.py +++ /dev/null @@ -1,141 +0,0 @@ -from itertools import product - -from website.notifications.emails import compile_subscriptions -from website.notifications import utils, constants - - -def get_file_subs_from_folder(addon, user, kind, path, name): - """Find the file tree under a specified folder.""" - folder = dict(kind=kind, path=path, name=name) - file_tree = addon._get_file_tree(filenode=folder, user=user, version='latest-published') - return list_of_files(file_tree) - - -def list_of_files(file_object): - files = [] - if file_object['kind'] == 'file': - return [file_object['path']] - else: - for child in file_object['children']: - files.extend(list_of_files(child)) - return files - - -def compile_user_lists(files, user, source_node, node): - """Take multiple file ids and compiles them. - - :param files: List of WaterButler paths - :param user: User who initiated action/event - :param source_node: Node instance from - :param node: Node instance to - :return: move, warn, and remove dicts - """ - # initialise subscription dictionaries - move = {key: [] for key in constants.NOTIFICATION_TYPES} - warn = {key: [] for key in constants.NOTIFICATION_TYPES} - remove = {key: [] for key in constants.NOTIFICATION_TYPES} - # get the node subscription - if len(files) == 0: - move, warn, remove = categorize_users( - user, 'file_updated', source_node, 'file_updated', node - ) - # iterate through file subscriptions - for file_path in files: - path = file_path.strip('/') - t_move, t_warn, t_remove = categorize_users( - user, path + '_file_updated', source_node, - path + '_file_updated', node - ) - # Add file subs to overall list of subscriptions - for notification in constants.NOTIFICATION_TYPES: - move[notification] = list(set(move[notification]).union(set(t_move[notification]))) - warn[notification] = list(set(warn[notification]).union(set(t_warn[notification]))) - remove[notification] = list(set(remove[notification]).union(set(t_remove[notification]))) - return move, warn, remove - - -def categorize_users(user, source_event, source_node, event, node): - """Categorize users from a file subscription into three categories. - - Puts users in one of three bins: - - Moved: User has permissions on both nodes, subscribed to both - - Warned: User has permissions on both, not subscribed to destination - - Removed: Does not have permission on destination node - :param user: User instance who started the event - :param source_event: _event_name - :param source_node: node from where the event happened - :param event: new guid event name - :param node: node where event ends up - :return: Moved, to be warned, and removed users. - """ - remove = utils.users_to_remove(source_event, source_node, node) - source_node_subs = compile_subscriptions(source_node, utils.find_subscription_type(source_event)) - new_subs = compile_subscriptions(node, utils.find_subscription_type(source_event), event) - - # Moves users into the warn bucket or the move bucket - move = subscriptions_users_union(source_node_subs, new_subs) - warn = subscriptions_users_difference(source_node_subs, new_subs) - - # Removes users without permissions - warn, remove = subscriptions_node_permissions(node, warn, remove) - - # Remove duplicates - warn = subscriptions_users_remove_duplicates(warn, new_subs, remove_same=False) - move = subscriptions_users_remove_duplicates(move, new_subs, remove_same=False) - - # Remove duplicates between move and warn; and move and remove - move = subscriptions_users_remove_duplicates(move, warn, remove_same=True) - move = subscriptions_users_remove_duplicates(move, remove, remove_same=True) - - for notifications in constants.NOTIFICATION_TYPES: - # Remove the user who started this whole thing. - user_id = user._id - if user_id in warn[notifications]: - warn[notifications].remove(user_id) - if user_id in move[notifications]: - move[notifications].remove(user_id) - if user_id in remove[notifications]: - remove[notifications].remove(user_id) - - return move, warn, remove - - -def subscriptions_node_permissions(node, warn_subscription, remove_subscription): - for notification in constants.NOTIFICATION_TYPES: - subbed, removed = utils.separate_users(node, warn_subscription[notification]) - warn_subscription[notification] = subbed - remove_subscription[notification].extend(removed) - remove_subscription[notification] = list(set(remove_subscription[notification])) - return warn_subscription, remove_subscription - - -def subscriptions_users_union(emails_1, emails_2): - return { - notification: - list( - set(emails_1[notification]).union(set(emails_2[notification])) - ) - for notification in constants.NOTIFICATION_TYPES.keys() - } - - -def subscriptions_users_difference(emails_1, emails_2): - return { - notification: - list( - set(emails_1[notification]).difference(set(emails_2[notification])) - ) - for notification in constants.NOTIFICATION_TYPES.keys() - } - - -def subscriptions_users_remove_duplicates(emails_1, emails_2, remove_same=False): - emails_list = dict(emails_1) - product_list = product(constants.NOTIFICATION_TYPES, repeat=2) - for notification_1, notification_2 in product_list: - if notification_2 == notification_1 and not remove_same or notification_2 == 'none': - continue - emails_list[notification_1] = list( - set(emails_list[notification_1]).difference(set(emails_2[notification_2])) - ) - return emails_list diff --git a/website/notifications/exceptions.py b/website/notifications/exceptions.py deleted file mode 100644 index 573a58164d3..00000000000 --- a/website/notifications/exceptions.py +++ /dev/null @@ -1,7 +0,0 @@ -from osf.exceptions import OSFError - -class InvalidSubscriptionError(OSFError): - """Raised if an invalid subscription is attempted. e.g. attempt to - subscribe to an invalid target: institution, bookmark, deleted project etc. - """ - pass diff --git a/website/notifications/listeners.py b/website/notifications/listeners.py deleted file mode 100644 index 21aed1df9e3..00000000000 --- a/website/notifications/listeners.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging -from website.notifications.exceptions import InvalidSubscriptionError -from website.notifications.utils import subscribe_user_to_notifications, subscribe_user_to_global_notifications -from website.project.signals import contributor_added, project_created -from framework.auth.signals import user_confirmed - -logger = logging.getLogger(__name__) - -@project_created.connect -def subscribe_creator(node): - if node.is_collection or node.is_deleted: - return None - try: - subscribe_user_to_notifications(node, node.creator) - except InvalidSubscriptionError as err: - user = node.creator._id if node.creator else 'None' - logger.warning(f'Skipping subscription of user {user} to node {node._id}') - logger.warning(f'Reason: {str(err)}') - -@contributor_added.connect -def subscribe_contributor(node, contributor, auth=None, *args, **kwargs): - try: - subscribe_user_to_notifications(node, contributor) - except InvalidSubscriptionError as err: - logger.warning(f'Skipping subscription of user {contributor} to node {node._id}') - logger.warning(f'Reason: {str(err)}') - -@user_confirmed.connect -def subscribe_confirmed_user(user): - try: - subscribe_user_to_global_notifications(user) - except InvalidSubscriptionError as err: - logger.warning(f'Skipping subscription of user {user} to global subscriptions') - logger.warning(f'Reason: {str(err)}') diff --git a/website/notifications/tasks.py b/website/notifications/tasks.py deleted file mode 100644 index 6b7353ccdc0..00000000000 --- a/website/notifications/tasks.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Tasks for making even transactional emails consolidated. -""" -import itertools - -from django.db import connection - -from framework.celery_tasks import app as celery_app -from framework.sentry import log_message -from osf.models import ( - OSFUser, - AbstractNode, - AbstractProvider, - RegistrationProvider, - CollectionProvider, - NotificationDigest, -) -from osf.registrations.utils import get_registration_provider_submissions_url -from osf.utils.permissions import ADMIN -from website import mails, settings -from website.notifications.utils import NotificationsDict - - -@celery_app.task(name='website.notifications.tasks.send_users_email', max_retries=0) -def send_users_email(send_type): - """Send pending emails. - - :param send_type - :return: - """ - _send_global_and_node_emails(send_type) - _send_reviews_moderator_emails(send_type) - - -def _send_global_and_node_emails(send_type): - """ - Called by `send_users_email`. Send all global and node-related notification emails. - """ - grouped_emails = get_users_emails(send_type) - for group in grouped_emails: - user = OSFUser.load(group['user_id']) - if not user: - log_message(f"User with id={group['user_id']} not found") - continue - info = group['info'] - notification_ids = [message['_id'] for message in info] - sorted_messages = group_by_node(info) - if sorted_messages: - if not user.is_disabled: - # If there's only one node in digest we can show it's preferences link in the template. - notification_nodes = list(sorted_messages['children'].keys()) - node = AbstractNode.load(notification_nodes[0]) if len( - notification_nodes) == 1 else None - mails.send_mail( - to_addr=user.username, - can_change_node_preferences=bool(node), - node=node, - mail=mails.DIGEST, - name=user.fullname, - message=sorted_messages, - ) - remove_notifications(email_notification_ids=notification_ids) - - -def _send_reviews_moderator_emails(send_type): - """ - Called by `send_users_email`. Send all reviews triggered emails. - """ - grouped_emails = get_moderators_emails(send_type) - for group in grouped_emails: - user = OSFUser.load(group['user_id']) - info = group['info'] - notification_ids = [message['_id'] for message in info] - provider = AbstractProvider.objects.get(id=group['provider_id']) - additional_context = dict() - if isinstance(provider, RegistrationProvider): - provider_type = 'registration' - submissions_url = get_registration_provider_submissions_url(provider) - withdrawals_url = f'{submissions_url}?state=pending_withdraw' - notification_settings_url = f'{settings.DOMAIN}registries/{provider._id}/moderation/notifications' - if provider.brand: - additional_context = { - 'logo_url': provider.brand.hero_logo_image, - 'top_bar_color': provider.brand.primary_color - } - elif isinstance(provider, CollectionProvider): - provider_type = 'collection' - submissions_url = f'{settings.DOMAIN}collections/{provider._id}/moderation/' - notification_settings_url = f'{settings.DOMAIN}registries/{provider._id}/moderation/notifications' - if provider.brand: - additional_context = { - 'logo_url': provider.brand.hero_logo_image, - 'top_bar_color': provider.brand.primary_color - } - withdrawals_url = '' - else: - provider_type = 'preprint' - submissions_url = f'{settings.DOMAIN}reviews/preprints/{provider._id}', - withdrawals_url = '' - notification_settings_url = f'{settings.DOMAIN}reviews/{provider_type}s/{provider._id}/notifications' - - if not user.is_disabled: - mails.send_mail( - to_addr=user.username, - mail=mails.DIGEST_REVIEWS_MODERATORS, - name=user.fullname, - message=info, - provider_name=provider.name, - reviews_submissions_url=submissions_url, - notification_settings_url=notification_settings_url, - reviews_withdrawal_url=withdrawals_url, - is_reviews_moderator_notification=True, - is_admin=provider.get_group(ADMIN).user_set.filter(id=user.id).exists(), - provider_type=provider_type, - **additional_context - ) - remove_notifications(email_notification_ids=notification_ids) - - -def get_moderators_emails(send_type): - """Get all emails for reviews moderators that need to be sent, grouped by users AND providers. - :param send_type: from NOTIFICATION_TYPES, could be "email_digest" or "email_transactional" - :return Iterable of dicts of the form: - [ - 'user_id': 'se8ea', - 'provider_id': '1', - 'info': [ - { - 'message': 'Hana Xie submitted Gravity', - '_id': NotificationDigest._id, - } - ], - ] - """ - sql = """ - SELECT json_build_object( - 'user_id', osf_guid._id, - 'provider_id', nd.provider_id, - 'info', json_agg( - json_build_object( - 'message', nd.message, - '_id', nd._id - ) - ) - ) - FROM osf_notificationdigest AS nd - LEFT JOIN osf_guid ON nd.user_id = osf_guid.object_id - WHERE send_type = %s AND (event = 'new_pending_submissions' OR event = 'new_pending_withdraw_requests') - AND osf_guid.content_type_id = (SELECT id FROM django_content_type WHERE model = 'osfuser') - GROUP BY osf_guid.id, nd.provider_id - ORDER BY osf_guid.id ASC - """ - - with connection.cursor() as cursor: - cursor.execute(sql, [send_type, ]) - return itertools.chain.from_iterable(cursor.fetchall()) - - -def get_users_emails(send_type): - """Get all emails that need to be sent. - NOTE: These do not include reviews triggered emails for moderators. - - :param send_type: from NOTIFICATION_TYPES - :return: Iterable of dicts of the form: - { - 'user_id': 'se8ea', - 'info': [{ - 'message': { - 'message': 'Freddie commented on your project Open Science', - 'timestamp': datetime object - }, - 'node_lineage': ['parent._id', 'node._id'], - '_id': NotificationDigest._id - }, ... - }] - { - 'user_id': ... - } - } - """ - - sql = """ - SELECT json_build_object( - 'user_id', osf_guid._id, - 'info', json_agg( - json_build_object( - 'message', nd.message, - 'node_lineage', nd.node_lineage, - '_id', nd._id - ) - ) - ) - FROM osf_notificationdigest AS nd - LEFT JOIN osf_guid ON nd.user_id = osf_guid.object_id - WHERE send_type = %s - AND event != 'new_pending_submissions' - AND event != 'new_pending_withdraw_requests' - AND osf_guid.content_type_id = (SELECT id FROM django_content_type WHERE model = 'osfuser') - GROUP BY osf_guid.id - ORDER BY osf_guid.id ASC - """ - - with connection.cursor() as cursor: - cursor.execute(sql, [send_type, ]) - return itertools.chain.from_iterable(cursor.fetchall()) - - -def group_by_node(notifications, limit=15): - """Take list of notifications and group by node. - - :param notifications: List of stored email notifications - :return: - """ - emails = NotificationsDict() - for notification in notifications[:15]: - emails.add_message(notification['node_lineage'], notification['message']) - return emails - - -def remove_notifications(email_notification_ids=None): - """Remove sent emails. - - :param email_notification_ids: - :return: - """ - if email_notification_ids: - NotificationDigest.objects.filter(_id__in=email_notification_ids).delete() diff --git a/website/notifications/utils.py b/website/notifications/utils.py index bc79781abc4..de3b5cfacf7 100644 --- a/website/notifications/utils.py +++ b/website/notifications/utils.py @@ -3,15 +3,11 @@ from django.apps import apps from django.db.models import Q -from framework.postcommit_tasks.handlers import run_postcommit from osf.utils.permissions import READ from website.notifications import constants from website.notifications.exceptions import InvalidSubscriptionError from website.project import signals -from framework.celery_tasks import app - - class NotificationsDict(dict): def __init__(self): super().__init__() @@ -78,44 +74,6 @@ def remove_contributor_from_subscriptions(node, user): for subscription in node_subscriptions: subscription.remove_user_from_subscription(user) - -@signals.node_deleted.connect -def remove_subscription(node): - remove_subscription_task(node._id) - -@signals.node_deleted.connect -def remove_supplemental_node(node): - remove_supplemental_node_from_preprints(node._id) - -@run_postcommit(once_per_request=False, celery=True) -@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') - - node = AbstractNode.load(node_id) - NotificationSubscription.objects.filter(node=node).delete() - parent = node.parent_node - - if parent and parent.child_node_subscriptions: - for user_id in parent.child_node_subscriptions: - if node._id in parent.child_node_subscriptions[user_id]: - parent.child_node_subscriptions[user_id].remove(node._id) - parent.save() - - -@run_postcommit(once_per_request=False, celery=True) -@app.task(max_retries=5, default_retry_delay=60) -def remove_supplemental_node_from_preprints(node_id): - AbstractNode = apps.get_model('osf.AbstractNode') - - node = AbstractNode.load(node_id) - for preprint in node.preprints.all(): - if preprint.node is not None: - preprint.node = None - preprint.save() - - def separate_users(node, user_ids): """Separates users into ones with permissions and ones without given a list. :param node: Node to separate based on permissions diff --git a/website/notifications/views.py b/website/notifications/views.py index 8ca4775367d..45aed44f50e 100644 --- a/website/notifications/views.py +++ b/website/notifications/views.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from rest_framework import status as http_status from flask import request @@ -6,23 +7,443 @@ from framework.auth.decorators import must_be_logged_in from framework.exceptions import HTTPError -from osf.models import AbstractNode, NotificationSubscription, Registration -from osf.utils.permissions import READ -from website.notifications import utils -from website.notifications.constants import NOTIFICATION_TYPES +from osf.models import AbstractNode, Registration +NOTIFICATION_TYPES = {} +USER_SUBSCRIPTIONS_AVAILABLE = {} +NODE_SUBSCRIPTIONS_AVAILABLE = {} from website.project.decorators import must_be_valid_project +import collections + +from django.apps import apps +from django.db.models import Q + +from osf.models import NotificationSubscription +from osf.utils.permissions import READ + + +class NotificationsDict(dict): + def __init__(self): + super().__init__() + self.update(messages=[], children=collections.defaultdict(NotificationsDict)) + + def add_message(self, keys, messages): + """ + :param keys: ordered list of project ids from parent to node (e.g. ['parent._id', 'node._id']) + :param messages: built email message for an event that occurred on the node + :return: nested dict with project/component ids as the keys with the message at the appropriate level + """ + d_to_use = self + + for key in keys: + d_to_use = d_to_use['children'][key] + + if not isinstance(messages, list): + messages = [messages] + + d_to_use['messages'].extend(messages) + + +def find_subscription_type(subscription): + """Find subscription type string within specific subscription. + Essentially removes extraneous parts of the string to get the type. + """ + subs_available = list(USER_SUBSCRIPTIONS_AVAILABLE.keys()) + subs_available.extend(list(NODE_SUBSCRIPTIONS_AVAILABLE.keys())) + for available in subs_available: + if available in subscription: + return available + + +def to_subscription_key(uid, event): + """Build the Subscription primary key for the given guid and event""" + return f'{uid}_{event}' + + +def from_subscription_key(key): + parsed_key = key.split('_', 1) + return { + 'uid': parsed_key[0], + 'event': parsed_key[1] + } + + +def users_to_remove(source_event, source_node, new_node): + """Find users that do not have permissions on new_node. + :param source_event: such as _file_updated + :param source_node: Node instance where a subscription currently resides + :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') + removed_users = {key: [] for key in NOTIFICATION_TYPES} + if source_node == new_node: + return removed_users + old_sub = NotificationSubscription.objects.get( + subscribed_object=source_node, + notification_type__name=source_event + ) + for notification_type in NOTIFICATION_TYPES: + users = [] + if hasattr(old_sub, notification_type): + users += list(getattr(old_sub, notification_type).values_list('guids___id', flat=True)) + return removed_users + + +def move_subscription(remove_users, source_event, source_node, new_event, new_node): + """Moves subscription from old_node to new_node + :param remove_users: dictionary of lists of users to remove from the subscription + :param source_event: A specific guid event _file_updated + :param source_node: Instance of Node + :param new_event: A specific guid event + :param new_node: Instance of Node + :return: Returns a NOTIFICATION_TYPES list of removed users without permissions + """ + NotificationSubscription = apps.get_model('osf.NotificationSubscription') + OSFUser = apps.get_model('osf.OSFUser') + if source_node == new_node: + return + old_sub = NotificationSubscription.load(to_subscription_key(source_node._id, source_event)) + if not old_sub: + return + elif old_sub: + old_sub._id = to_subscription_key(new_node._id, new_event) + old_sub.event_name = new_event + old_sub.owner = new_node + new_sub = old_sub + new_sub.save() + # Remove users that don't have permission on the new node. + for notification_type in NOTIFICATION_TYPES: + if new_sub: + for user_id in remove_users[notification_type]: + related_manager = getattr(new_sub, notification_type, None) + subscriptions = related_manager.all() if related_manager else [] + if user_id in subscriptions: + user = OSFUser.load(user_id) + new_sub.remove_user_from_subscription(user) + + +def get_configured_projects(user): + """Filter all user subscriptions for ones that are on parent projects + and return the node objects. + :param user: OSFUser object + :return: list of node objects for projects with no parent + """ + configured_projects = set() + user_subscriptions = get_all_user_subscriptions(user, extra=( + ~Q(node__type='osf.collection') & + Q(node__is_deleted=False) + )) + + for subscription in user_subscriptions: + # If the user has opted out of emails skip + node = subscription.owner + + if ( + (subscription.none.filter(id=user.id).exists() and not node.parent_id) or + node._id not in user.notifications_configured + ): + continue + + root = node.root + + if not root.is_deleted: + configured_projects.add(root) + + return sorted(configured_projects, key=lambda n: n.title.lower()) + + +def check_project_subscriptions_are_all_none(user, node): + node_subscriptions = NotificationSubscription.objects.filter( + user=user, + object_id=node.id, + content_type=ContentType.objects.get_for_model(node).id, + ) + for s in node_subscriptions: + if not s.message_frequecy == 'none': + return False + return True + + +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( + user=user, + ) + return queryset.filter(extra) if extra else queryset + + +def get_all_node_subscriptions(user, node, user_subscriptions=None): + """ Get all Subscription objects for a node that the user is subscribed to + :param user: OSFUser object + :param node: Node object + :param user_subscriptions: all Subscription objects that the user is subscribed to + :return: list of Subscription objects for a node that the user is subscribed to + """ + if not user_subscriptions: + user_subscriptions = get_all_user_subscriptions(user) + return user_subscriptions.filter( + object_id=node.id, + content_type=ContentType.objects.get_for_model(node).id, + ) + + +def format_data(user, nodes): + """ Format subscriptions data for project settings page + :param user: OSFUser object + :param nodes: list of parent project node objects + :return: treebeard-formatted data + """ + items = [] + + user_subscriptions = get_all_user_subscriptions(user) + for node in nodes: + assert node, f'{node._id} is not a valid Node.' + + can_read = node.has_permission(user, READ) + can_read_children = node.has_permission_on_children(user, READ) + + if not can_read and not can_read_children: + continue + + children = node.get_nodes(**{'is_deleted': False, 'is_node_link': False}) + children_tree = [] + # List project/node if user has at least READ permissions (contributor or admin viewer) or if + # user is contributor on a component of the project/node + + if can_read: + node_sub_available = list(NODE_SUBSCRIPTIONS_AVAILABLE.keys()) + subscriptions = get_all_node_subscriptions(user, node, user_subscriptions=user_subscriptions).filter(event_name__in=node_sub_available) + + for subscription in subscriptions: + index = node_sub_available.index(getattr(subscription, 'event_name')) + children_tree.append(serialize_event(user, subscription=subscription, + node=node, event_description=node_sub_available.pop(index))) + for node_sub in node_sub_available: + children_tree.append(serialize_event(user, node=node, event_description=node_sub)) + children_tree.sort(key=lambda s: s['event']['title']) + + children_tree.extend(format_data(user, children)) + + item = { + 'node': { + 'id': node._id, + 'url': node.url if can_read else '', + 'title': node.title if can_read else 'Private Project', + }, + 'children': children_tree, + 'kind': 'folder' if not node.parent_node or not node.parent_node.has_permission(user, READ) else 'node', + 'nodeType': node.project_or_component, + 'category': node.category, + 'permissions': { + 'view': can_read, + }, + } + + items.append(item) + + return items + + +def format_user_subscriptions(user): + """ Format user-level subscriptions (e.g. comment replies across the OSF) for user settings page""" + user_subs_available = list(USER_SUBSCRIPTIONS_AVAILABLE.keys()) + subscriptions = [ + serialize_event( + user, subscription, + event_description=user_subs_available.pop(user_subs_available.index(getattr(subscription, 'event_name'))) + ) + for subscription in get_all_user_subscriptions(user) + if subscription is not None and getattr(subscription, 'event_name') in user_subs_available + ] + subscriptions.extend([serialize_event(user, event_description=sub) for sub in user_subs_available]) + return subscriptions + + +def format_file_subscription(user, node_id, path, provider): + """Format a single file event""" + AbstractNode = apps.get_model('osf.AbstractNode') + node = AbstractNode.load(node_id) + wb_path = path.lstrip('/') + for subscription in get_all_node_subscriptions(user, node): + if wb_path in getattr(subscription, 'event_name'): + return serialize_event(user, subscription, node) + return serialize_event(user, node=node, event_description='file_updated') + +def serialize_event(user, subscription=None, node=None, event_description=None): + """ + :param user: OSFUser object + :param subscription: Subscription object, use if parsing particular subscription + :param node: Node object, use if node is known + :param event_description: use if specific subscription is known + :return: treebeard-formatted subscription event + """ + if not event_description: + event_description = getattr(subscription, 'event_name') + # Looks at only the types available. Deals with pre-pending file names. + for sub_type in {}: + if sub_type in event_description: + event_type = sub_type + else: + event_type = event_description + if node and node.parent_node: + notification_type = 'adopt_parent' + elif event_type.startswith('global_'): + notification_type = 'email_transactional' + else: + notification_type = 'none' + if subscription: + for n_type in {}: + if getattr(subscription, n_type).filter(id=user.id).exists(): + notification_type = n_type + return { + 'event': { + 'title': event_description, + 'description': {}[event_type], + 'notificationType': notification_type, + 'parent_notification_type': get_parent_notification_type(node, event_type, user) + }, + 'kind': 'event', + 'children': [] + } + + +def get_parent_notification_type(node, event, user): + """ + Given an event on a node (e.g. comment on node 'xyz'), find the user's notification + type on the parent project for the same event. + :param obj node: event owner (Node or User object) + :param str event: notification event (e.g. 'comment_replies') + :param obj user: OSFUser object + :return: str notification type (e.g. 'email_transactional') + """ + AbstractNode = apps.get_model('osf.AbstractNode') + 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 = NotificationSubscriptionLegacy.objects.get(_id=key) + except NotificationSubscriptionLegacy.DoesNotExist: + return get_parent_notification_type(parent, event, user) + + for notification_type in NOTIFICATION_TYPES: + if getattr(subscription, notification_type).filter(id=user.id).exists(): + return notification_type + else: + return get_parent_notification_type(parent, event, user) + else: + return None + + +def get_global_notification_type(global_subscription, user): + """ + Given a global subscription (e.g. NotificationSubscription object with event_type + 'global_file_updated'), find the user's notification type. + :param obj global_subscription: NotificationSubscription object + :param obj user: OSFUser object + :return: str notification type (e.g. 'email_transactional') + """ + for notification_type in NOTIFICATION_TYPES: + # TODO Optimize me + if getattr(global_subscription, notification_type).filter(id=user.id).exists(): + return notification_type + + +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 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): + NotificationSubscriptionLegacy = apps.get_model('osf.NotificationSubscriptionLegacy') + notification_type = 'email_transactional' + user_events = 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 = 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() + + +class InvalidSubscriptionError: + pass + + +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') + Preprint = apps.get_model('osf.Preprint') + DraftRegistration = apps.get_model('osf.DraftRegistration') + if isinstance(node, Preprint): + raise InvalidSubscriptionError('Preprints are invalid targets for subscriptions at this time.') + + if isinstance(node, DraftRegistration): + raise InvalidSubscriptionError('DraftRegistrations are invalid targets for subscriptions at this time.') + + if node.is_collection: + raise InvalidSubscriptionError('Collections are invalid targets for subscriptions') + + if node.is_deleted: + raise InvalidSubscriptionError('Deleted Nodes are invalid targets for subscriptions') + + if getattr(node, 'is_registration', False): + raise InvalidSubscriptionError('Registrations are invalid targets for subscriptions') + + events = NODE_SUBSCRIPTIONS_AVAILABLE + + if user.is_registered: + for event in events: + subscription, _ = NotificationSubscription.objects.get_or_create( + user=user, + notification_type__name=event + ) + + +def format_user_and_project_subscriptions(user): + """ Format subscriptions data for user settings page. """ + return [ + { + 'node': { + 'id': 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': format_user_subscriptions(user) + }, + { + 'node': { + 'id': '', + 'title': 'Project Notifications', + 'help': 'These are settings for each of your projects. Modifying ' + + 'these settings will only modify the settings for the selected project.' + }, + 'kind': 'heading', + 'children': format_data(user, get_configured_projects(user)) + }] @must_be_logged_in def get_subscriptions(auth): - return utils.format_user_and_project_subscriptions(auth.user) + return format_user_and_project_subscriptions(auth.user) @must_be_logged_in @must_be_valid_project def get_node_subscriptions(auth, **kwargs): node = kwargs.get('node') or kwargs['project'] - return utils.format_data(auth.user, [node]) + return format_data(auth.user, [node]) @must_be_logged_in @@ -30,7 +451,7 @@ def get_file_subscriptions(auth, **kwargs): node_id = request.args.get('node_id') path = request.args.get('path') provider = request.args.get('provider') - return utils.format_file_subscription(auth.user, node_id, path, provider) + return format_file_subscription(auth.user, node_id, path, provider) @must_be_logged_in @@ -52,7 +473,7 @@ def configure_subscription(auth): if 'file_updated' in event and path is not None and provider is not None: wb_path = path.lstrip('/') event = wb_path + '_file_updated' - event_id = utils.to_subscription_key(target_id, event) + event_id = to_subscription_key(target_id, event) if not node: # if target_id is not a node it currently must be the current user @@ -68,7 +489,7 @@ def configure_subscription(auth): f'{user!r} attempted to adopt_parent of a none node id, {target_id}' ) raise HTTPError(http_status.HTTP_400_BAD_REQUEST) - owner = user + # owner = user else: if not node.has_permission(user, READ): sentry.log_message(f'{user!r} attempted to subscribe to private node, {target_id}') @@ -81,7 +502,8 @@ def configure_subscription(auth): raise HTTPError(http_status.HTTP_400_BAD_REQUEST) if notification_type != 'adopt_parent': - owner = node + pass + # owner = node else: if 'file_updated' in event and len(event) > len('file_updated'): pass @@ -95,25 +517,27 @@ 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, _ = NotificationSubscription.objects.get_or_create( + user=user, + subscribed_object=node, + notification_type__name=event + ) if not subscription: return {} # We're done here subscription.remove_user_from_subscription(user) return {} - subscription = NotificationSubscription.load(event_id) - - if not subscription: - subscription = NotificationSubscription(_id=event_id, owner=owner, event_name=event) - subscription.save() + subscription, _ = NotificationSubscription.objects.get_or_create( + user=user, + notification_type__name=event + ) + subscription.save() if node and node._id not in user.notifications_configured: user.notifications_configured[node._id] = True user.save() - subscription.add_user_to_subscription(user, notification_type) - subscription.save() return {'message': f'Successfully subscribed to {notification_type} list on {event_id}'} diff --git a/website/profile/views.py b/website/profile/views.py index c4d49147454..e16ce3f915f 100644 --- a/website/profile/views.py +++ b/website/profile/views.py @@ -26,10 +26,9 @@ from framework.utils import throttle_period_expired from osf import features -from osf.models import ApiOAuth2Application, ApiOAuth2PersonalToken, OSFUser +from osf.models import ApiOAuth2Application, ApiOAuth2PersonalToken, OSFUser, NotificationType from osf.exceptions import BlockedEmailError, OSFError from osf.utils.requests import string_type_request_headers -from website import mails from website import mailchimp_utils from website import settings from website import language @@ -188,16 +187,16 @@ def update_user(auth): # make sure the new username has already been confirmed if username and username != user.username and user.emails.filter(address=username).exists(): - - mails.send_mail( - user.username, - mails.PRIMARY_EMAIL_CHANGED, + NotificationType.Type.USER_PRIMARY_EMAIL_CHANGED.instance.emit( + subscribed_object=user, user=user, - new_address=username, - can_change_preferences=False, - osf_contact_email=settings.OSF_CONTACT_EMAIL + event_context={ + 'user_fullname': user.fullname, + 'new_address': username, + 'can_change_preferences': False, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + } ) - # Remove old primary email from subscribed mailing lists for list_name, subscription in user.mailchimp_mailing_lists.items(): if subscription: @@ -799,11 +798,14 @@ def request_export(auth): data={'message_long': 'Too many requests. Please wait a while before sending another account export request.', 'error_type': 'throttle_error'}) - mails.send_mail( - to_addr=settings.OSF_SUPPORT_EMAIL, - mail=mails.REQUEST_EXPORT, - user=auth.user, - can_change_preferences=False, + NotificationType.Type.DESK_REQUEST_EXPORT.instance.emit( + user=user, + event_context={ + 'user_username': user.username, + 'user_absolute_url': user.absolute_url, + 'user__id': user._id, + 'can_change_preferences': False, + } ) user.email_last_sent = timezone.now() user.save() diff --git a/website/project/views/comment.py b/website/project/views/comment.py index 5e274052f18..968f8cb7c2e 100644 --- a/website/project/views/comment.py +++ b/website/project/views/comment.py @@ -14,7 +14,8 @@ @file_updated.connect -def update_file_guid_referent(self, target, event_type, payload, user=None): +def update_file_guid_referent(self, target, payload, user=None): + event_type = payload['action'] if event_type not in ('addon_file_moved', 'addon_file_renamed'): return # Nothing to do diff --git a/website/project/views/contributor.py b/website/project/views/contributor.py index f3e06aff3fc..95edc5db1d8 100644 --- a/website/project/views/contributor.py +++ b/website/project/views/contributor.py @@ -17,13 +17,20 @@ from framework.sessions import get_session from framework.transactions.handlers import no_auto_transaction from framework.utils import get_timestamp, throttle_period_expired -from osf.models import Tag +from osf.models import Tag, Node from osf.exceptions import NodeStateError -from osf.models import AbstractNode, DraftRegistration, OSFUser, Preprint, PreprintProvider, RecentlyAddedContributor +from osf.models import ( + AbstractNode, + DraftRegistration, + OSFUser, + Preprint, + PreprintProvider, + RecentlyAddedContributor, + NotificationType +) from osf.utils import sanitize from osf.utils.permissions import ADMIN -from website import mails, language, settings -from website.notifications.utils import check_if_all_global_subscriptions_are_none +from website import language, settings from website.profile import utils as profile_utils from website.project.decorators import (must_have_permission, must_be_valid_project, must_not_be_registration, must_be_contributor_or_public, must_be_contributor) @@ -203,14 +210,25 @@ def deserialize_contributors(node, user_dicts, auth, validate=False): @unreg_contributor_added.connect -def finalize_invitation(node, contributor, auth, email_template='default'): +def finalize_invitation( + node, + contributor, + auth, + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT +): try: record = contributor.get_unclaimed_record(node._primary_key) except ValueError: pass else: if record['email']: - send_claim_email(record['email'], contributor, node, notify=True, email_template=email_template) + send_claim_email( + record['email'], + contributor, + node, + notify=True, + notification_type=notification_type + ) @must_be_valid_project @@ -252,7 +270,11 @@ def project_contributors_post(auth, node, **kwargs): except ValidationError as e: return {'status': 400, 'message': e.message}, 400 - child.add_contributors(contributors=child_contribs, auth=auth) + child.add_contributors( + contributors=child_contribs, + auth=auth, + notification_type=False + ) child.save() # Reconnect listeners unreg_contributor_added.connect(finalize_invitation) @@ -381,7 +403,6 @@ def project_remove_contributor(auth, **kwargs): return redirect_url -# TODO: consider moving this into utils def send_claim_registered_email(claimer, unclaimed_user, node, throttle=24 * 3600): """ A registered user claiming the unclaimed user account as an contributor to a project. @@ -396,13 +417,7 @@ def send_claim_registered_email(claimer, unclaimed_user, node, throttle=24 * 360 """ unclaimed_record = unclaimed_user.get_unclaimed_record(node._primary_key) - # check throttle - timestamp = unclaimed_record.get('last_sent') - if not throttle_period_expired(timestamp, throttle): - raise HTTPError(http_status.HTTP_400_BAD_REQUEST, data=dict( - message_long='User account can only be claimed with an existing user once every 24 hours' - )) # roll the valid token for each email, thus user cannot change email and approve a different email address verification_key = generate_verification_key(verification_type='claim') @@ -419,205 +434,226 @@ def send_claim_registered_email(claimer, unclaimed_user, node, throttle=24 * 360 token=unclaimed_record['token'], _absolute=True, ) + if check_email_throttle( + referrer, + notification_type=NotificationType.Type.USER_FORWARD_INVITE_REGISTERED, + throttle=throttle + ): + raise HTTPError( + http_status.HTTP_400_BAD_REQUEST, + data=dict( + message_long='User account can only be claimed with an existing user once every 24 hours' + ) + ) # Send mail to referrer, telling them to forward verification link to claimer - mails.send_mail( - referrer.username, - mails.FORWARD_INVITE_REGISTERED, - user=unclaimed_user, - referrer=referrer, - node=node, - claim_url=claim_url, - fullname=unclaimed_record['name'], - can_change_preferences=False, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + NotificationType.Type.USER_FORWARD_INVITE_REGISTERED.instance.emit( + user=referrer, + event_context={ + 'claim_url': claim_url, + 'referrer_fullname': referrer.fullname, + 'user_fullname': unclaimed_record['name'], + 'node_title': node.title, + 'can_change_preferences': False, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + } ) - unclaimed_record['last_sent'] = get_timestamp() - unclaimed_user.save() - # Send mail to claimer, telling them to wait for referrer - mails.send_mail( - claimer.username, - mails.PENDING_VERIFICATION_REGISTERED, - fullname=claimer.fullname, - referrer=referrer, - node=node, - can_change_preferences=False, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + NotificationType.Type.USER_PENDING_VERIFICATION_REGISTERED.instance.emit( + subscribed_object=claimer, + user=claimer, + event_context={ + 'claim_url': claim_url, + 'user_fullname': unclaimed_record['name'], + 'referrer_username': referrer.username, + 'referrer_fullname': referrer.fullname, + 'node_title': node.title, + 'can_change_preferences': False, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + } ) - -# TODO: consider moving this into utils -def send_claim_email(email, unclaimed_user, node, notify=True, throttle=24 * 3600, email_template='default'): +def send_claim_email( + email, + unclaimed_user, + node, + notify=True, + throttle=24 * 3600, + notification_type=NotificationType.Type.NODE_CONTRIBUTOR_ADDED_DEFAULT +): """ - Unregistered user claiming a user account as an contributor to a project. Send an email for claiming the account. - Either sends to the given email or the referrer's email, depending on the email address provided. - - :param str email: The address given in the claim user form - :param User unclaimed_user: The User record to claim. - :param Node node: The node where the user claimed their account. - :param bool notify: If True and an email is sent to the referrer, an email - will also be sent to the invited user about their pending verification. - :param int throttle: Time period (in seconds) after the referrer is - emailed during which the referrer will not be emailed again. - :param str email_template: the email template to use - :return - :raise http_status.HTTP_400_BAD_REQUEST - + Send a claim email to an unregistered contributor or the referrer, depending on the scenario. + + Args: + email (str): Email address provided for claim. + unclaimed_user (User): The user record to claim. + node (Node): The node where the user claimed their account. + notify (bool): Whether to notify the invited user about their pending verification. + throttle (int): Throttle period (in seconds) to prevent repeated emails. + notification_type (str): The notification_type identifier. + Returns: + str: The address the notification was sent to. + Raises: + HTTPError: If the throttle period has not expired. """ claimer_email = email.lower().strip() unclaimed_record = unclaimed_user.get_unclaimed_record(node._primary_key) referrer = OSFUser.load(unclaimed_record['referrer_id']) - claim_url = unclaimed_user.get_claim_url(node._primary_key, external=True) - - # Option 1: - # When adding the contributor, the referrer provides both name and email. - # The given email is the same provided by user, just send to that email. logo = None + + # Option 1: Referrer provided name and email (send to claimer) if unclaimed_record.get('email') == claimer_email: - # check email template for branded preprints - if email_template == 'preprint': - if node.provider.is_default: - mail_tpl = mails.INVITE_OSF_PREPRINT - logo = settings.OSF_PREPRINTS_LOGO - else: - mail_tpl = mails.INVITE_PREPRINT(node.provider) - logo = node.provider._id - elif email_template == 'draft_registration': - mail_tpl = mails.INVITE_DRAFT_REGISTRATION - else: - mail_tpl = mails.INVITE_DEFAULT + # Select notification type and logo using match + match notification_type: + case 'preprint': + if getattr(node.provider, 'is_default', False): + notification_type = NotificationType.Type.USER_INVITE_OSF_PREPRINT + logo = settings.OSF_PREPRINTS_LOGO + else: + notification_type = NotificationType.Type.PROVIDER_USER_INVITE_PREPRINT + logo = getattr(node.provider, '_id', None) + case 'draft_registration': + notification_type = NotificationType.Type.DRAFT_REGISTRATION_CONTRIBUTOR_ADDED_DEFAULT + case _: + notification_type = NotificationType.Type.USER_INVITE_DEFAULT - to_addr = claimer_email unclaimed_record['claimer_email'] = claimer_email unclaimed_user.save() - # Option 2: - # TODO: [new improvement ticket] this option is disabled from preprint but still available on the project page - # When adding the contributor, the referred only provides the name. - # The account is later claimed by some one who provides the email. - # Send email to the referrer and ask her/him to forward the email to the user. + + # Option 2: Referrer only provided name (send to referrer) else: - # check throttle timestamp = unclaimed_record.get('last_sent') if not throttle_period_expired(timestamp, throttle): - raise HTTPError(http_status.HTTP_400_BAD_REQUEST, data=dict( - message_long='User account can only be claimed with an existing user once every 24 hours' - )) - # roll the valid token for each email, thus user cannot change email and approve a different email address + raise HTTPError( + http_status.HTTP_400_BAD_REQUEST, + data={'message_long': 'User account can only be claimed with an existing user once every 24 hours'} + ) verification_key = generate_verification_key(verification_type='claim') - unclaimed_record['last_sent'] = get_timestamp() - unclaimed_record['token'] = verification_key['token'] - unclaimed_record['expires'] = verification_key['expires'] - unclaimed_record['claimer_email'] = claimer_email + unclaimed_record.update({ + 'last_sent': get_timestamp(), + 'token': verification_key['token'], + 'expires': verification_key['expires'], + 'claimer_email': claimer_email, + }) unclaimed_user.save() - claim_url = unclaimed_user.get_claim_url(node._primary_key, external=True) - # send an email to the invited user without `claim_url` if notify: - pending_mail = mails.PENDING_VERIFICATION - mails.send_mail( - claimer_email, - pending_mail, + NotificationType.Type.USER_PENDING_VERIFICATION.instance.emit( + subscribed_object=unclaimed_user, user=unclaimed_user, - referrer=referrer, - fullname=unclaimed_record['name'], - node=node, - can_change_preferences=False, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + event_context={ + 'referrer_fullname': referrer.fullname, + 'user_fullname': unclaimed_record['name'], + 'node_title': node.title, + 'logo': logo, + 'can_change_preferences': False, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + } ) - mail_tpl = mails.FORWARD_INVITE - to_addr = referrer.username - - # Send an email to the claimer (Option 1) or to the referrer (Option 2) with `claim_url` - mails.send_mail( - to_addr, - mail_tpl, - user=unclaimed_user, - referrer=referrer, - node=node, - claim_url=claim_url, - email=claimer_email, - fullname=unclaimed_record['name'], - branded_service=node.provider, - can_change_preferences=False, - logo=logo if logo else settings.OSF_LOGO, - osf_contact_email=settings.OSF_CONTACT_EMAIL, + + notification_type = NotificationType.Type.USER_FORWARD_INVITE + claim_url = unclaimed_user.get_claim_url(node._primary_key, external=True) + + notification_type.instance.emit( + user=referrer, + destination_address=email, + event_context={ + 'user_fullname': referrer.id, + 'referrer_fullname': referrer.fullname, + 'fullname': unclaimed_record['name'], + 'node_url': node.url, + 'logo': logo, + 'claim_url': claim_url, + 'can_change_preferences': False, + 'domain': settings.DOMAIN, + 'node_absolute_url': node.absolute_url, + 'node_title': node.title, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + } ) - return to_addr +def check_email_throttle( + user, + notification_type, + throttle=settings.CONTRIBUTOR_ADDED_EMAIL_THROTTLE +): + """ + Check whether a 'contributor added' notification was sent recently + (within the throttle period) for the given node and contributor. -def check_email_throttle(node, contributor, throttle=None): - throttle = throttle or settings.CONTRIBUTOR_ADDED_EMAIL_THROTTLE - contributor_record = contributor.contributor_added_email_records.get(node._id, {}) - if contributor_record: - timestamp = contributor_record.get('last_sent', None) - if timestamp: - if not throttle_period_expired(timestamp, throttle): - return True - else: - contributor.contributor_added_email_records[node._id] = {} + Args: + user (OSFUser): The contributor being notified. + notification_type (str, optional): What type of notification to check for. + + Returns: + bool: True if throttled (email was sent recently), False otherwise. + """ + from osf.models import NotificationSubscription + from datetime import timedelta + # Check for an active subscription for this contributor and this node + subscription = NotificationSubscription.objects.filter( + user=user, + notification_type=notification_type.instance, + ).order_by('created').first() + if not subscription: + return False # No subscription means no previous notifications, so no throttling + # Check the most recent Notification for this subscription + return subscription.notifications.order_by('created').first().created > timezone.now() - timedelta(seconds=throttle) @contributor_added.connect -def notify_added_contributor(node, contributor, auth=None, email_template='default', throttle=None, *args, **kwargs): - logo = settings.OSF_LOGO - if check_email_throttle(node, contributor, throttle=throttle): - return - if email_template == 'false': - return - if not getattr(node, 'is_published', True): - return - if not contributor.is_registered: - unreg_contributor_added.send( - node, - contributor=contributor, - auth=auth, - email_template=email_template - ) +def notify_added_contributor(resource, contributor, notification_type, auth=None, *args, **kwargs): + """Send a notification to a contributor who was just added to a node. + + Handles: + - Unregistered contributor invitations. + - Registered contributor notifications. + - Throttle checks to avoid repeated emails. + + Args: + node (AbstractNode): The node to which the contributor was added. + contributor (OSFUser): The user being added. + auth (Auth, optional): Authorization context. + notification_type (str, optional): Template identifier. + """ + if not notification_type: return - # Email users for projects, or for components where they are not contributors on the parent node. - contrib_on_parent_node = isinstance(node, (Preprint, DraftRegistration)) or \ - (not node.parent_node or (node.parent_node and not node.parent_node.is_contributor(contributor))) - if contrib_on_parent_node: - if email_template == 'preprint': - if node.provider.is_default: - email_template = mails.CONTRIBUTOR_ADDED_OSF_PREPRINT - logo = settings.OSF_PREPRINTS_LOGO - else: - email_template = mails.CONTRIBUTOR_ADDED_PREPRINT(node.provider) - logo = node.provider._id - elif email_template == 'draft_registration': - email_template = mails.CONTRIBUTOR_ADDED_DRAFT_REGISTRATION - elif email_template == 'access_request': - email_template = mails.CONTRIBUTOR_ADDED_ACCESS_REQUEST - elif node.has_linked_published_preprints: - # Project holds supplemental materials for a published preprint - email_template = mails.CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF - logo = settings.OSF_PREPRINTS_LOGO - else: - email_template = mails.CONTRIBUTOR_ADDED_DEFAULT - - mails.send_mail( - to_addr=contributor.username, - mail=email_template, - user=contributor, - node=node, - referrer_name=auth.user.fullname if auth else '', - is_initiator=getattr(auth, 'user', False) == contributor, - all_global_subscriptions_none=check_if_all_global_subscriptions_are_none(contributor), - branded_service=node.provider, - can_change_preferences=False, - logo=logo, - osf_contact_email=settings.OSF_CONTACT_EMAIL, - published_preprints=[] if isinstance(node, (Preprint, DraftRegistration)) else serialize_preprints(node, user=None) - ) - - contributor.contributor_added_email_records[node._id]['last_sent'] = get_timestamp() - contributor.save() + logo = settings.OSF_LOGO + if getattr(resource, 'has_linked_published_preprints', None): + notification_type = NotificationType.Type.PREPRINT_CONTRIBUTOR_ADDED_PREPRINT_NODE_FROM_OSF + logo = settings.OSF_PREPRINTS_LOGO + throttle = kwargs.get('throttle', settings.CONTRIBUTOR_ADDED_EMAIL_THROTTLE) + if notification_type and check_email_throttle(contributor, notification_type, throttle=throttle): + return + referrer_name = getattr(getattr(auth, 'user', None), 'fullname', '') if auth else '' + notification_type.instance.emit( + user=contributor, + subscribed_object=resource, + event_context={ + 'user_fullname': contributor.fullname, + 'referrer_text': referrer_name + ' has added you' if referrer_name else 'You have been add', + 'registry_text': resource.provider.name if resource.provider else 'OSF Registry', + 'referrer_name': referrer_name, + 'domain': settings.DOMAIN, + 'is_initiator': getattr(getattr(auth, 'user', None), 'id', None) == contributor.id if auth else False, + 'branded_service__id': getattr(getattr(resource, 'provider', None), '_id', None), + 'branded_service_name': getattr(getattr(resource, 'provider', None), 'name', None), + 'branded_service_preprint_word': getattr(getattr(resource, 'provider', None), 'preprint_word', None), + 'node_title': resource.title, + 'node_id': resource._id, + 'node_provider__id': getattr(resource.provider, '_id', None), + 'node_absolute_url': resource.absolute_url, + 'node_has_permission_admin': resource.has_permission(user=contributor, permission='admin'), + 'can_change_preferences': False, + 'logo': logo, + 'osf_contact_email': settings.OSF_CONTACT_EMAIL, + 'preprint_list': ''.join(f"- {p['absolute_url']}\n" for p in serialize_preprints(resource, user=None)) if isinstance(resource, Node) else '- (none)\n' + } + ) @contributor_added.connect def add_recently_added_contributor(node, contributor, auth=None, *args, **kwargs): @@ -732,7 +768,6 @@ def claim_user_registered(auth, node, **kwargs): if should_claim: node.replace_contributor(old=unreg_user, new=current_user) node.save() - status.push_status_message( 'You are now a contributor to this project.', kind='success', @@ -942,7 +977,7 @@ def claim_user_post(node, **kwargs): claimer = get_user(email=email) # registered user if claimer and claimer.is_registered: - send_claim_registered_email(claimer, unclaimed_user, node) + send_claim_registered_email(claimer, unclaimed_user, node, email) # unregistered user else: send_claim_email(email, unclaimed_user, node, notify=True) diff --git a/website/reviews/listeners.py b/website/reviews/listeners.py index 27a15c2c337..1d90d7ac337 100644 --- a/website/reviews/listeners.py +++ b/website/reviews/listeners.py @@ -1,83 +1,62 @@ from django.utils import timezone -from website.notifications import utils -from website.mails import mails +from website.settings import DOMAIN, OSF_PREPRINTS_LOGO, OSF_REGISTRIES_LOGO from website.reviews import signals as reviews_signals -from website.settings import OSF_PREPRINTS_LOGO, OSF_REGISTRIES_LOGO, DOMAIN +@reviews_signals.reviews_withdraw_requests_notification_moderators.connect +def reviews_withdraw_requests_notification_moderators(self, timestamp, context, user, resource): + context['referrer_fullname'] = user.fullname + provider = resource.provider + from osf.models import NotificationType -@reviews_signals.reviews_email.connect -def reviews_notification(self, creator, template, context, action): - """ - Handle email notifications including: update comment, accept, and reject of submission, but not initial submission - or resubmission. - """ - # Avoid AppRegistryNotReady error - from website.notifications.emails import notify_global_event - recipients = list(action.target.contributors) - time_now = action.created if action is not None else timezone.now() - node = action.target - notify_global_event( - event='global_reviews', - sender_user=creator, - node=node, - timestamp=time_now, - recipients=recipients, - template=template, - context=context - ) + context['message'] = f'has requested withdrawal of "{resource.title}".' + context['reviews_submission_url'] = f'{DOMAIN}reviews/registries/{provider._id}/{resource._id}' + for recipient in provider.get_group('moderator').user_set.all(): + context['user_fullname'] = recipient.fullname + context['recipient_fullname'] = recipient.fullname -@reviews_signals.reviews_email_submit.connect -def reviews_submit_notification(self, recipients, context, template=None): - """ - Handle email notifications for a new submission or a resubmission - """ - if not template: - template = mails.REVIEWS_SUBMISSION_CONFIRMATION + NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS.instance.emit( + user=recipient, + subscribed_object=provider, + event_context=context, + is_digest=True, + ) - # Avoid AppRegistryNotReady error - from website.notifications.emails import get_user_subscriptions +@reviews_signals.reviews_email_withdrawal_requests.connect +def reviews_withdrawal_requests_notification(self, timestamp, context): + preprint = context.pop('reviewable') + context['reviewable_absolute_url'] = preprint.absolute_url + context['reviewable_title'] = preprint.title + context['reviewable__id'] = preprint._id + from osf.models import NotificationType - event_type = utils.find_subscription_type('global_reviews') + preprint_word = preprint.provider.preprint_word + context['message'] = f'has requested withdrawal of the {preprint_word} "{preprint.title}".' + context['reviews_submission_url'] = f'{DOMAIN}reviews/preprints/{preprint.provider._id}/{preprint._id}' - provider = context['reviewable'].provider - if provider._id == 'osf': - if provider.type == 'osf.preprintprovider': - context['logo'] = OSF_PREPRINTS_LOGO - elif provider.type == 'osf.registrationprovider': - context['logo'] = OSF_REGISTRIES_LOGO - else: - raise NotImplementedError() - else: - context['logo'] = context['reviewable'].provider._id + for recipient in preprint.provider.subscribed_object.get_group('moderator').user_set.all(): + context['user_fullname'] = recipient.fullname + context['recipient_fullname'] = recipient.fullname - for recipient in recipients: - user_subscriptions = get_user_subscriptions(recipient, event_type) - context['no_future_emails'] = user_subscriptions['none'] - context['is_creator'] = recipient == context['reviewable'].creator - context['provider_name'] = context['reviewable'].provider.name - mails.send_mail( - recipient.username, - template, + NotificationType.Type.PROVIDER_NEW_PENDING_WITHDRAW_REQUESTS.instance.emit( user=recipient, - **context + event_context=context, + subscribed_object=preprint.provider, + is_digest=True, ) - @reviews_signals.reviews_email_submit_moderators_notifications.connect -def reviews_submit_notification_moderators(self, timestamp, context): +def reviews_submit_notification_moderators(self, timestamp, resource, 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 website.profile.utils import get_profile_image_url - from website.notifications.emails import store_emails - resource = context['reviewable'] provider = resource.provider - + context['reviews_submission_url'] = ( + f'{DOMAIN}reviews/preprints/{provider._id}/{resource._id}' + ) # Set submission url if provider.type == 'osf.preprintprovider': context['reviews_submission_url'] = ( @@ -88,9 +67,6 @@ def reviews_submit_notification_moderators(self, timestamp, context): else: raise NotImplementedError(f'unsupported provider type {provider.type}') - # Set url for profile image of the submitter - context['profile_image_url'] = get_profile_image_url(context['referrer']) - # Set message revision_id = context.get('revision_id') if revision_id: @@ -102,138 +78,54 @@ def reviews_submit_notification_moderators(self, timestamp, context): else: context['message'] = f'submitted "{resource.title}".' - # Get NotificationSubscription instance, which contains reference to all subscribers - provider_subscription, created = NotificationSubscription.objects.get_or_create( - _id=f'{provider._id}_new_pending_submissions', - provider=provider - ) + from osf.models import NotificationType + context['requester_contributor_names'] = ''.join(resource.contributors.values_list('fullname', flat=True)) + context['localized_timestamp'] = str(timezone.now()) - # "transactional" subscribers receive notifications "Immediately" (i.e. at 5 minute intervals) - # "digest" subscribers receive emails daily - recipients_per_subscription_type = { - 'email_transactional': list( - provider_subscription.email_transactional.all().values_list('guids___id', flat=True) - ), - 'email_digest': list( - provider_subscription.email_digest.all().values_list('guids___id', flat=True) - ) - } - - for subscription_type, recipient_ids in recipients_per_subscription_type.items(): - if not recipient_ids: - continue - - store_emails( - recipient_ids, - subscription_type, - 'new_pending_submissions', - context['referrer'], - resource, - timestamp, - abstract_provider=provider, - **context + for recipient in resource.provider.get_group('moderator').user_set.all(): + context['recipient_fullname'] = recipient.fullname + context['user_fullname'] = recipient.fullname + context['requester_fullname'] = recipient.fullname + context['is_request_email'] = False + + NotificationType.Type.PROVIDER_NEW_PENDING_SUBMISSIONS.instance.emit( + user=recipient, + subscribed_object=provider, + event_context=context, + is_digest=True, ) -# Handle email notifications to notify moderators of new submissions. -@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 website.profile.utils import get_profile_image_url - from website.notifications.emails import store_emails - resource = context['reviewable'] +@reviews_signals.reviews_email_submit.connect +def reviews_submit_notification(self, recipients, context, resource, notification_type=None): + """ + Handle email notifications for a new submission or a resubmission + """ provider = resource.provider + if provider._id == 'osf': + if provider.type == 'osf.preprintprovider': + context['logo'] = OSF_PREPRINTS_LOGO + elif provider.type == 'osf.registrationprovider': + context['logo'] = OSF_REGISTRIES_LOGO + else: + raise NotImplementedError() + else: + context['logo'] = resource.provider._id - # Get NotificationSubscription instance, which contains reference to all subscribers - provider_subscription, created = NotificationSubscription.objects.get_or_create( - _id=f'{provider._id}_new_pending_withdraw_requests', - provider=provider - ) - - # Set message - context['message'] = f'has requested withdrawal of "{resource.title}".' - # Set url for profile image of the submitter - context['profile_image_url'] = get_profile_image_url(context['referrer']) - # Set submission url - context['reviews_submission_url'] = f'{DOMAIN}reviews/registries/{provider._id}/{resource._id}' - - email_transactional_ids = list(provider_subscription.email_transactional.all().values_list('guids___id', flat=True)) - email_digest_ids = list(provider_subscription.email_digest.all().values_list('guids___id', flat=True)) - - # Store emails to be sent to subscribers instantly (at a 5 min interval) - store_emails( - email_transactional_ids, - 'email_transactional', - 'new_pending_withdraw_requests', - context['referrer'], - resource, - timestamp, - abstract_provider=provider, - template='new_pending_submissions', - **context - ) - - # Store emails to be sent to subscribers daily - store_emails( - email_digest_ids, - 'email_digest', - 'new_pending_withdraw_requests', - context['referrer'], - resource, - timestamp, - abstract_provider=provider, - template='new_pending_submissions', - **context - ) - -# Handle email notifications to notify moderators of new withdrawal requests -@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 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( - '{}_new_pending_submissions'.format(context['reviewable'].provider._id)) - preprint = context['reviewable'] - preprint_word = preprint.provider.preprint_word - - # Set message - context['message'] = f'has requested withdrawal of the {preprint_word} "{preprint.title}".' - # Set url for profile image of the submitter - context['profile_image_url'] = get_profile_image_url(context['requester']) - # Set submission url - context['reviews_submission_url'] = '{}reviews/preprints/{}/{}'.format(settings.DOMAIN, - preprint.provider._id, - preprint._id) - - email_transactional_ids = list(provider_subscription.email_transactional.all().values_list('guids___id', flat=True)) - email_digest_ids = list(provider_subscription.email_digest.all().values_list('guids___id', flat=True)) - - # Store emails to be sent to subscribers instantly (at a 5 min interval) - store_emails( - email_transactional_ids, - 'email_transactional', - 'new_pending_submissions', - context['requester'], - preprint, - timestamp, - abstract_provider=preprint.provider, - **context - ) + context['no_future_emails'] = resource.provider.allow_submissions + context['is_request_email'] = False + if resource.actions.last(): + context['requester_fullname'] = resource.actions.last().creator.fullname + else: + context['requester_fullname'] = '' - # Store emails to be sent to subscribers daily - store_emails( - email_digest_ids, - 'email_digest', - 'new_pending_submissions', - context['requester'], - preprint, - timestamp, - abstract_provider=preprint.provider, - **context - ) + for recipient in recipients: + context['is_creator'] = recipient == resource.creator + context['provider_name'] = resource.provider.name + context['user_fullname'] = recipient.username + notification_type.instance.emit( + user=recipient, + subscribed_object=provider, + event_context=context, + is_digest=True, + ) diff --git a/website/settings/defaults.py b/website/settings/defaults.py index 80cc6b18ed1..13ff55ac21f 100644 --- a/website/settings/defaults.py +++ b/website/settings/defaults.py @@ -12,6 +12,8 @@ from collections import OrderedDict import enum +from celery.schedules import crontab + os_env = os.environ @@ -140,9 +142,7 @@ def parent_dir(path): # External services USE_CDN_FOR_CLIENT_LIBS = True -USE_EMAIL = True FROM_EMAIL = 'openscienceframework-noreply@osf.io' - # support email OSF_SUPPORT_EMAIL = 'support@osf.io' # contact email @@ -157,6 +157,8 @@ def parent_dir(path): # SMTP Settings MAIL_SERVER = 'smtp.sendgrid.net' +MAIL_PORT = 1025 + MAIL_USERNAME = 'osf-smtp' MAIL_PASSWORD = '' # Set this in local.py @@ -166,10 +168,7 @@ def parent_dir(path): # OR, if using Sendgrid's API # WARNING: If `SENDGRID_WHITELIST_MODE` is True, -# `tasks.send_email` would only email recipients included in `SENDGRID_EMAIL_WHITELIST` SENDGRID_API_KEY = None -SENDGRID_WHITELIST_MODE = False -SENDGRID_EMAIL_WHITELIST = [] # Mailchimp MAILCHIMP_API_KEY = None @@ -179,6 +178,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' @@ -435,12 +435,10 @@ class CeleryConfig: 'scripts.generate_sitemap', 'osf.management.commands.clear_expired_sessions', 'osf.management.commands.delete_withdrawn_or_failed_registration_files', - 'osf.management.commands.check_crossref_dois', 'osf.management.commands.find_spammy_files', 'osf.management.commands.migrate_pagecounter_data', 'osf.management.commands.migrate_deleted_date', 'osf.management.commands.addon_deleted_date', - 'osf.management.commands.migrate_registration_responses', 'osf.management.commands.archive_registrations_on_IA' 'osf.management.commands.sync_doi_metadata', 'osf.management.commands.sync_collection_provider_indices', @@ -456,10 +454,9 @@ class CeleryConfig: med_pri_modules = { 'framework.email.tasks', - 'scripts.send_queued_mails', 'scripts.triggered_mails', 'website.mailchimp_utils', - 'website.notifications.tasks', + 'notifications.tasks', 'website.collections.tasks', 'website.identifiers.tasks', 'website.preprints.tasks', @@ -550,12 +547,11 @@ class CeleryConfig: # Modules to import when celery launches imports = ( 'framework.celery_tasks', - 'framework.email.tasks', 'osf.external.chronos.tasks', 'osf.management.commands.data_storage_usage', 'osf.management.commands.registration_schema_metrics', 'website.mailchimp_utils', - 'website.notifications.tasks', + 'notifications.tasks', 'website.archiver.tasks', 'website.identifiers.tasks', 'website.search.search', @@ -567,20 +563,16 @@ class CeleryConfig: 'scripts.approve_registrations', 'scripts.approve_embargo_terminations', 'scripts.triggered_mails', - 'scripts.send_queued_mails', 'scripts.generate_sitemap', 'scripts.premigrate_created_modified', 'scripts.add_missing_identifiers_to_preprints', 'osf.management.commands.clear_expired_sessions', 'osf.management.commands.deactivate_requested_accounts', - 'osf.management.commands.check_crossref_dois', - 'osf.management.commands.find_spammy_files', 'osf.management.commands.update_institution_project_counts', 'osf.management.commands.correct_registration_moderation_states', 'osf.management.commands.sync_collection_provider_indices', 'osf.management.commands.sync_datacite_doi_metadata', 'osf.management.commands.archive_registrations_on_IA', - 'osf.management.commands.populate_initial_schema_responses', 'osf.management.commands.approve_pending_schema_responses', 'osf.management.commands.sync_doi_metadata', 'api.providers.tasks', @@ -598,156 +590,110 @@ class CeleryConfig: # 'scripts.analytics.upload', # ) - # celery.schedule will not be installed when running invoke requirements the first time. - try: - from celery.schedules import crontab - except ImportError: - pass - else: - # Setting up a scheduler, essentially replaces an independent cron job - # Note: these times must be in UTC - beat_schedule = { - '5-minute-emails': { - 'task': 'website.notifications.tasks.send_users_email', - 'schedule': crontab(minute='*/5'), - 'args': ('email_transactional',), - }, - 'daily-emails': { - 'task': 'website.notifications.tasks.send_users_email', - 'schedule': crontab(minute=0, hour=5), # Daily at 12 a.m. EST - 'args': ('email_digest',), - }, - # 'refresh_addons': { # Handled by GravyValet now - # 'task': 'scripts.refresh_addon_tokens', - # 'schedule': crontab(minute=0, hour=7), # Daily 2:00 a.m - # 'kwargs': {'dry_run': False, 'addons': { - # 'box': 60, # https://docs.box.com/docs/oauth-20#section-6-using-the-access-and-refresh-tokens - # 'googledrive': 14, # https://developers.google.com/identity/protocols/OAuth2#expiration - # 'mendeley': 14 # http://dev.mendeley.com/reference/topics/authorization_overview.html - # }}, - # }, - 'retract_registrations': { - 'task': 'scripts.retract_registrations', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - 'embargo_registrations': { - 'task': 'scripts.embargo_registrations', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - 'add_missing_identifiers_to_preprints': { - 'task': 'scripts.add_missing_identifiers_to_preprints', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - 'approve_registrations': { - 'task': 'scripts.approve_registrations', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - 'approve_embargo_terminations': { - 'task': 'scripts.approve_embargo_terminations', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - 'triggered_mails': { - 'task': 'scripts.triggered_mails', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - 'clear_expired_sessions': { - 'task': 'osf.management.commands.clear_expired_sessions', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - 'send_queued_mails': { - 'task': 'scripts.send_queued_mails', - 'schedule': crontab(minute=0, hour=17), # Daily 12 p.m. - 'kwargs': {'dry_run': False}, - }, - 'new-and-noteworthy': { - 'task': 'scripts.populate_new_and_noteworthy_projects', - 'schedule': crontab(minute=0, hour=7, day_of_week=6), # Saturday 2:00 a.m. - 'kwargs': {'dry_run': False} - }, - 'registration_schema_metrics': { - 'task': 'management.commands.registration_schema_metrics', - 'schedule': crontab(minute=45, hour=7, day_of_month=3), # Third day of month 2:45 a.m. - 'kwargs': {'dry_run': False} - }, - 'daily_reporters_go': { - 'task': 'management.commands.daily_reporters_go', - 'schedule': crontab(minute=0, hour=6), # Daily 1:00 a.m. - }, - 'monthly_reporters_go': { - 'task': 'management.commands.monthly_reporters_go', - 'schedule': crontab(minute=30, hour=6, day_of_month=2), # Second day of month 1:30 a.m. - }, - # 'data_storage_usage': { - # 'task': 'management.commands.data_storage_usage', - # 'schedule': crontab(day_of_month=1, minute=30, hour=4), # Last of the month at 11:30 p.m. - # }, - # 'migrate_pagecounter_data': { - # 'task': 'management.commands.migrate_pagecounter_data', - # 'schedule': crontab(minute=0, hour=7), # Daily 2:00 a.m. - # }, - # 'migrate_registration_responses': { - # 'task': 'management.commands.migrate_registration_responses', - # 'schedule': crontab(minute=32, hour=7), # Daily 2:32 a.m. - # 'migrate_deleted_date': { - # 'task': 'management.commands.migrate_deleted_date', - # 'schedule': crontab(minute=0, hour=3), - # 'addon_deleted_date': { - # 'task': 'management.commands.addon_deleted_date', - # 'schedule': crontab(minute=0, hour=3), # Daily 11:00 p.m. - # }, - # 'populate_branched_from': { - # 'task': 'management.commands.populate_branched_from', - # 'schedule': crontab(minute=0, hour=3), - # }, - 'generate_sitemap': { - 'task': 'scripts.generate_sitemap', - 'schedule': crontab(minute=0, hour=5), # Daily 12:00 a.m. - }, - 'deactivate_requested_accounts': { - 'task': 'management.commands.deactivate_requested_accounts', - 'schedule': crontab(minute=0, hour=5), # Daily 12:00 a.m. - }, - 'check_crossref_doi': { - 'task': 'management.commands.check_crossref_dois', - 'schedule': crontab(minute=0, hour=4), # Daily 11:00 p.m. - }, - 'update_institution_project_counts': { - 'task': 'management.commands.update_institution_project_counts', - 'schedule': crontab(minute=0, hour=9), # Daily 05:00 a.m. EDT - }, -# 'archive_registrations_on_IA': { -# 'task': 'osf.management.commands.archive_registrations_on_IA', -# 'schedule': crontab(minute=0, hour=5), # Daily 4:00 a.m. -# 'kwargs': {'dry_run': False} -# }, - 'delete_withdrawn_or_failed_registration_files': { - 'task': 'management.commands.delete_withdrawn_or_failed_registration_files', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': { - 'dry_run': False, - 'batch_size_withdrawn': 10, - 'batch_size_stuck': 10 - } - }, - 'monitor_registration_bulk_upload_jobs': { - 'task': 'api.providers.tasks.monitor_registration_bulk_upload_jobs', - # 'schedule': crontab(hour='*/3'), # Every 3 hours - 'schedule': crontab(minute='*/5'), # Every 5 minutes for staging server QA test - 'kwargs': {'dry_run': False} - }, - 'approve_registration_updates': { - 'task': 'osf.management.commands.approve_pending_schema_responses', - 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m - 'kwargs': {'dry_run': False}, - }, - } + # Setting up a scheduler, essentially replaces an independent cron job + # Note: these times must be in UTC + beat_schedule = { + 'retract_registrations': { + 'task': 'scripts.retract_registrations', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'embargo_registrations': { + 'task': 'scripts.embargo_registrations', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'add_missing_identifiers_to_preprints': { + 'task': 'scripts.add_missing_identifiers_to_preprints', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'approve_registrations': { + 'task': 'scripts.approve_registrations', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'approve_embargo_terminations': { + 'task': 'scripts.approve_embargo_terminations', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'triggered_mails': { + 'task': 'scripts.triggered_mails', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'send_moderators_digest_email': { + 'task': 'notifications.tasks.send_moderators_digest_email', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'send_users_digest_email': { + 'task': 'notifications.tasks.send_users_digest_email', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'clear_expired_sessions': { + 'task': 'osf.management.commands.clear_expired_sessions', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + 'new-and-noteworthy': { + 'task': 'scripts.populate_new_and_noteworthy_projects', + 'schedule': crontab(minute=0, hour=7, day_of_week=6), # Saturday 2:00 a.m. + 'kwargs': {'dry_run': False} + }, + 'registration_schema_metrics': { + 'task': 'management.commands.registration_schema_metrics', + 'schedule': crontab(minute=45, hour=7, day_of_month=3), # Third day of month 2:45 a.m. + 'kwargs': {'dry_run': False} + }, + 'daily_reporters_go': { + 'task': 'management.commands.daily_reporters_go', + 'schedule': crontab(minute=0, hour=6), # Daily 1:00 a.m. + }, + 'monthly_reporters_go': { + 'task': 'management.commands.monthly_reporters_go', + 'schedule': crontab(minute=30, hour=6, day_of_month=2), # Second day of month 1:30 a.m. + }, + 'generate_sitemap': { + 'task': 'scripts.generate_sitemap', + 'schedule': crontab(minute=0, hour=5), # Daily 12:00 a.m. + }, + 'deactivate_requested_accounts': { + 'task': 'management.commands.deactivate_requested_accounts', + 'schedule': crontab(minute=0, hour=5), # Daily 12:00 a.m. + }, + 'check_crossref_doi': { + 'task': 'management.commands.check_crossref_dois', + 'schedule': crontab(minute=0, hour=4), # Daily 11:00 p.m. + }, + 'update_institution_project_counts': { + 'task': 'management.commands.update_institution_project_counts', + 'schedule': crontab(minute=0, hour=9), # Daily 05:00 a.m. EDT + }, + 'delete_withdrawn_or_failed_registration_files': { + 'task': 'management.commands.delete_withdrawn_or_failed_registration_files', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': { + 'dry_run': False, + 'batch_size_withdrawn': 10, + 'batch_size_stuck': 10 + } + }, + 'monitor_registration_bulk_upload_jobs': { + 'task': 'api.providers.tasks.monitor_registration_bulk_upload_jobs', + # 'schedule': crontab(hour='*/3'), # Every 3 hours + 'schedule': crontab(minute='*/5'), # Every 5 minutes for staging server QA test + 'kwargs': {'dry_run': False} + }, + 'approve_registration_updates': { + 'task': 'osf.management.commands.approve_pending_schema_responses', + 'schedule': crontab(minute=0, hour=5), # Daily 12 a.m + 'kwargs': {'dry_run': False}, + }, + } + # Tasks that need metrics and release requirements # beat_schedule.update({ diff --git a/website/settings/local-ci.py b/website/settings/local-ci.py index c63fce5a86a..188f3d975ea 100644 --- a/website/settings/local-ci.py +++ b/website/settings/local-ci.py @@ -44,13 +44,17 @@ SEARCH_ENGINE = 'elastic' -USE_EMAIL = False USE_CELERY = False # Email -MAIL_SERVER = 'localhost:1025' # For local testing MAIL_USERNAME = 'osf-smtp' MAIL_PASSWORD = 'CHANGEME' +MAIL_SERVER = 'localhost' # For local testing +MAIL_PORT = 1025 # For local testing + +MAILHOG_HOST = 'localhost' +MAILHOG_PORT = 1025 +MAILHOG_API_HOST = 'http://localhost:8025' MAILHOG_HOST = 'localhost' MAILHOG_PORT = 1025 diff --git a/website/settings/local-dist.py b/website/settings/local-dist.py index 212b9926f7e..bd817c16302 100644 --- a/website/settings/local-dist.py +++ b/website/settings/local-dist.py @@ -57,10 +57,14 @@ ELASTIC_TIMEOUT = 10 # Email -USE_EMAIL = False -MAIL_SERVER = 'localhost:1025' # For local testing MAIL_USERNAME = 'osf-smtp' MAIL_PASSWORD = 'CHANGEME' +MAIL_SERVER = 'localhost' # For local testing +MAIL_PORT = 1025 # For local testing + +MAILHOG_HOST = 'mailhog' +MAILHOG_PORT = 1025 +MAILHOG_API_HOST = 'http://mailhog:8025' MAILHOG_HOST = 'mailhog' MAILHOG_PORT = 1025 @@ -108,11 +112,6 @@ class CeleryConfig(defaults.CeleryConfig): USE_CDN_FOR_CLIENT_LIBS = False -# WARNING: `SENDGRID_WHITELIST_MODE` should always be True in local dev env to prevent unintentional spamming. -# Add specific email addresses to `SENDGRID_EMAIL_WHITELIST` for testing purposes. -SENDGRID_WHITELIST_MODE = True -SENDGRID_EMAIL_WHITELIST = [] - # Example of extending default settings # defaults.IMG_FMTS += ["pdf"] @@ -145,9 +144,6 @@ class CeleryConfig(defaults.CeleryConfig): CHRONOS_USE_FAKE_FILE = True CHRONOS_FAKE_FILE_URL = 'https://staging2.osf.io/r2t5v/download' -# Show sent emails in console -logging.getLogger('website.mails.mails').setLevel(logging.DEBUG) - SHARE_ENABLED = False DATACITE_ENABLED = False diff --git a/website/templates/emails/access_request_rejected.html.mako b/website/templates/access_request_rejected.html.mako similarity index 76% rename from website/templates/emails/access_request_rejected.html.mako rename to website/templates/access_request_rejected.html.mako index d4d3bbb2f5c..a054f7822db 100644 --- a/website/templates/emails/access_request_rejected.html.mako +++ b/website/templates/access_request_rejected.html.mako @@ -3,12 +3,9 @@ <%def name="content()"> - <%! - from website import settings - %> - Hello ${requester.fullname},
+ Hello ${requester_fullname},

- This email is to inform you that your request for access to the project at ${node.absolute_url} has been declined.
+ This email is to inform you that your request for access to the project at ${node_absolute_url} has been declined.

Sincerely,

diff --git a/website/templates/emails/access_request_submitted.html.mako b/website/templates/access_request_submitted.html.mako similarity index 75% rename from website/templates/emails/access_request_submitted.html.mako rename to website/templates/access_request_submitted.html.mako index 0839a4e2b41..ded0f6d1e09 100644 --- a/website/templates/emails/access_request_submitted.html.mako +++ b/website/templates/access_request_submitted.html.mako @@ -3,12 +3,9 @@ <%def name="content()"> - <%! - from website import settings - %> - Hello ${admin.fullname},
+ Hello ${user_fullname},

- ${requester.fullname} has requested access to your ${node.project_or_component} "${node.title}."
+ ${requester_fullname} has requested access to your project "${node_title}."

To review the request, click here to allow or deny access and configure permissions.

diff --git a/website/templates/emails/add_sso_email_osf4i.html.mako b/website/templates/add_sso_email_osf4i.html.mako similarity index 96% rename from website/templates/emails/add_sso_email_osf4i.html.mako rename to website/templates/add_sso_email_osf4i.html.mako index 2253363c013..952d0185e99 100644 --- a/website/templates/emails/add_sso_email_osf4i.html.mako +++ b/website/templates/add_sso_email_osf4i.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
+ Hello ${user_fullname},

Thank you for connecting to OSF through your institution. This email address <${email_to_add}> has been added to your account as an alternate email address.

diff --git a/website/templates/emails/addons_boa_job_complete.html.mako b/website/templates/addons_boa_job_complete.html.mako similarity index 96% rename from website/templates/emails/addons_boa_job_complete.html.mako rename to website/templates/addons_boa_job_complete.html.mako index 738b5cb04eb..1ccc4ae5309 100644 --- a/website/templates/emails/addons_boa_job_complete.html.mako +++ b/website/templates/addons_boa_job_complete.html.mako @@ -7,7 +7,7 @@ - Hello ${fullname},
+ Hello ${user_fullname},

Your submission [${job_id}] of file [${query_file_full_path}] to Boa is successful.

diff --git a/website/templates/emails/addons_boa_job_failure.html.mako b/website/templates/addons_boa_job_failure.html.mako similarity index 99% rename from website/templates/emails/addons_boa_job_failure.html.mako rename to website/templates/addons_boa_job_failure.html.mako index 5ed46a042d7..d7c24d51714 100644 --- a/website/templates/emails/addons_boa_job_failure.html.mako +++ b/website/templates/addons_boa_job_failure.html.mako @@ -7,7 +7,7 @@ - Hello ${fullname},
+ Hello ${user_fullname},

Your submission of file [${query_file_full_path}] from your OSF project to Boa has failed.

diff --git a/website/templates/emails/archive_copy_error_desk.html.mako b/website/templates/archive_copy_error_desk.html.mako similarity index 71% rename from website/templates/emails/archive_copy_error_desk.html.mako rename to website/templates/archive_copy_error_desk.html.mako index 18938e306ac..17d927cd04d 100644 --- a/website/templates/emails/archive_copy_error_desk.html.mako +++ b/website/templates/archive_copy_error_desk.html.mako @@ -4,16 +4,15 @@ <%def name="content()"> - <% from website import settings %> -

Issue registering ${src.title}

+

Issue registering ${src_title}

- User: ${user.fullname} (${user.username}) [${user._id}] + User: ${user_fullname} [${user__id}] - Tried to register ${src.title} (${url}) [${src._id}], but the archive task failed when copying files. + Tried to register ${src_title} (${url}) [${src__id}], but the archive task failed when copying files.
A report is included below: diff --git a/website/templates/emails/archive_copy_error_user.html.mako b/website/templates/archive_copy_error_user.html.mako similarity index 76% rename from website/templates/emails/archive_copy_error_user.html.mako rename to website/templates/archive_copy_error_user.html.mako index 310bd4f5a6b..75b1efede98 100644 --- a/website/templates/emails/archive_copy_error_user.html.mako +++ b/website/templates/archive_copy_error_user.html.mako @@ -4,13 +4,12 @@ <%def name="content()"> - <% from website import settings %> -

Issue registering ${src.title}

+

Issue registering ${src_title}

- We cannot archive ${src.title} at this time because there were errors copying files from some of the linked third-party services. It's possible that this is due to temporary unavailability of one or more of these services and that retrying the registration may resolve this issue. Our development team is investigating this failure. We're sorry for any inconvenience this may have caused. + We cannot archive ${src_title} at this time because there were errors copying files from some of the linked third-party services. It's possible that this is due to temporary unavailability of one or more of these services and that retrying the registration may resolve this issue. Our development team is investigating this failure. We're sorry for any inconvenience this may have caused. diff --git a/website/templates/emails/archive_file_not_found_desk.html.mako b/website/templates/archive_file_not_found_desk.html.mako similarity index 72% rename from website/templates/emails/archive_file_not_found_desk.html.mako rename to website/templates/archive_file_not_found_desk.html.mako index b6cd3ecce12..f14c892c893 100644 --- a/website/templates/emails/archive_file_not_found_desk.html.mako +++ b/website/templates/archive_file_not_found_desk.html.mako @@ -4,15 +4,14 @@ <%def name="content()"> - <% from website import settings %> -

Issue registering ${src.title}

+

Issue registering ${src_title}

- User: ${user.fullname} (${user.username}) [${user._id}] + User: ${user_fullname} [${user__id}] - Tried to register ${src.title} (${url}) [${src._id}], but the archive task failed when copying files. At least one file selected in the registration schema was moved or deleted in between its selection and archival. + Tried to register ${src_title} (${url}) [${src__id}], but the archive task failed when copying files. At least one file selected in the registration schema was moved or deleted in between its selection and archival.
    % for missing in results['missing_files']: diff --git a/website/templates/emails/archive_file_not_found_user.html.mako b/website/templates/archive_file_not_found_user.html.mako similarity index 80% rename from website/templates/emails/archive_file_not_found_user.html.mako rename to website/templates/archive_file_not_found_user.html.mako index 3dc2caf28a8..a30886b5716 100644 --- a/website/templates/emails/archive_file_not_found_user.html.mako +++ b/website/templates/archive_file_not_found_user.html.mako @@ -4,13 +4,12 @@ <%def name="content()"> - <% from website import settings %> -

    Issue registering ${src.title}

    +

    Issue registering ${src_title}

    - Your registration for the project ${src.title} at ${src.absolute_url} failed because one of more of the following files have been altered since you attached them to the draft registration. To fix this problem, please go to your draft registration and select the files you want to be included in your registration. + Your registration for the project ${src_title} at ${src.absolute_url} failed because one of more of the following files have been altered since you attached them to the draft registration. To fix this problem, please go to your draft registration and select the files you want to be included in your registration.
      % for missing in results['missing_files']: diff --git a/website/templates/emails/archive_registration_stuck_desk.html.mako b/website/templates/archive_registration_stuck_desk.html.mako similarity index 83% rename from website/templates/emails/archive_registration_stuck_desk.html.mako rename to website/templates/archive_registration_stuck_desk.html.mako index f3fac20204a..f56cde7c054 100644 --- a/website/templates/emails/archive_registration_stuck_desk.html.mako +++ b/website/templates/archive_registration_stuck_desk.html.mako @@ -4,7 +4,7 @@ <%def name="content()"> -

      ${len(broken_registrations)} registrations found stuck in archiving

      +

      ${len(broken_registrations_count)} registrations found stuck in archiving

      diff --git a/website/templates/emails/archive_size_exceeded_desk.html.mako b/website/templates/archive_size_exceeded_desk.html.mako similarity index 53% rename from website/templates/emails/archive_size_exceeded_desk.html.mako rename to website/templates/archive_size_exceeded_desk.html.mako index 8b4376c1c0d..c56efe5c965 100644 --- a/website/templates/emails/archive_size_exceeded_desk.html.mako +++ b/website/templates/archive_size_exceeded_desk.html.mako @@ -1,17 +1,16 @@ <%inherit file="notify_base.mako" /> <%def name="content()"> -<% from website import settings %> -

      Issue registering ${src.title}

      +

      Issue registering ${src_title}

      - User: ${user.fullname} (${user.username}) [${user._id}] + User: ${user_fullname} [${user__id}] - Tried to register ${src.title} (${url}), but the resulting archive would have exceeded our caps for disk usage (${settings.MAX_ARCHIVE_SIZE / 1024 ** 3}GB). + Tried to register ${src_title} (${url}), but the resulting archive would have exceeded our caps for disk usage (${max_archive_size}GB).
      A report is included below: diff --git a/website/templates/emails/archive_size_exceeded_user.html.mako b/website/templates/archive_size_exceeded_user.html.mako similarity index 71% rename from website/templates/emails/archive_size_exceeded_user.html.mako rename to website/templates/archive_size_exceeded_user.html.mako index d30498bc222..19d82125b90 100644 --- a/website/templates/emails/archive_size_exceeded_user.html.mako +++ b/website/templates/archive_size_exceeded_user.html.mako @@ -3,13 +3,12 @@ <%def name="content()"> - <% from website import settings %> -

      Issue registering ${src.title}

      +

      Issue registering ${src_title}

      - We cannot archive ${src.title} at this time because the projected size of the registration exceeds our usage limits. You should receive a followup email from our support team shortly. We're sorry for any inconvenience this may have caused. + We cannot archive ${src_title} at this time because the projected size of the registration exceeds our usage limits. You should receive a followup email from our support team shortly. We're sorry for any inconvenience this may have caused. diff --git a/website/templates/emails/archive_success.html.mako b/website/templates/archive_success.html.mako similarity index 57% rename from website/templates/emails/archive_success.html.mako rename to website/templates/archive_success.html.mako index 2825623a048..853728dc820 100644 --- a/website/templates/emails/archive_success.html.mako +++ b/website/templates/archive_success.html.mako @@ -2,16 +2,15 @@ <%def name="content()"> -<% from website import settings %> -

      Registration of ${src.title} finished

      +

      Registration of ${src_title} finished

      - You can view the registration here. + You can view the registration here. diff --git a/website/templates/emails/archive_uncaught_error_desk.html.mako b/website/templates/archive_uncaught_error_desk.html.mako similarity index 66% rename from website/templates/emails/archive_uncaught_error_desk.html.mako rename to website/templates/archive_uncaught_error_desk.html.mako index 66997da4b44..55cdad04f0f 100644 --- a/website/templates/emails/archive_uncaught_error_desk.html.mako +++ b/website/templates/archive_uncaught_error_desk.html.mako @@ -4,15 +4,14 @@ <%def name="content()"> - <% from website import settings %> -

      Issue registering ${src.title}

      +

      Issue registering ${src_title}

      - User: ${user.fullname} (${user.username}) [${user._id}] + User: ${user_fullname} [${user__id}] - Tried to register ${src.title} (${url}) [${src._id}], but the archive task failed unexpectedly. + Tried to register ${src_title} (${url}) [${src__id}], but the archive task failed unexpectedly.
      A report is included below: diff --git a/website/templates/emails/archive_uncaught_error_user.html.mako b/website/templates/archive_uncaught_error_user.html.mako similarity index 70% rename from website/templates/emails/archive_uncaught_error_user.html.mako rename to website/templates/archive_uncaught_error_user.html.mako index 561895cdf8d..785331f8ee8 100644 --- a/website/templates/emails/archive_uncaught_error_user.html.mako +++ b/website/templates/archive_uncaught_error_user.html.mako @@ -4,13 +4,12 @@ <%def name="content()"> - <% from website import settings %> -

      Issue registering ${src.title}

      +

      Issue registering ${src_title}

      - We cannot archive ${src.title} at this time because there were errors copying files to the registration. Our development team is investigating this failure. We're sorry for any inconvenience this may have caused. + We cannot archive ${src_title} at this time because there were errors copying files to the registration. Our development team is investigating this failure. We're sorry for any inconvenience this may have caused. diff --git a/website/templates/emails/collection_submission_accepted.html.mako b/website/templates/collection_submission_accepted.html.mako similarity index 50% rename from website/templates/emails/collection_submission_accepted.html.mako rename to website/templates/collection_submission_accepted.html.mako index 08bd7524d0b..91ccf5fa540 100644 --- a/website/templates/emails/collection_submission_accepted.html.mako +++ b/website/templates/collection_submission_accepted.html.mako @@ -1,22 +1,19 @@ <%inherit file="notify_base.mako" /> -<%! - from website import settings -%> <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      % if is_admin: - Your request to add ${node.title} to - ${collection.provider.name} was approved. + Your request to add ${node_title} to + ${collection_provider_name} was approved. % else: - ${node.title} was added to ${collection.provider.name}. + ${node_title} was added to ${collection_provider_name}. % endif

      - If you are not ${user.fullname} or you have been erroneously associated with - ${node.title}, email ${osf_contact_email} with the subject line + If you are not ${user_fullname} or you have been erroneously associated with + ${node_title}, email ${osf_contact_email} with the subject line "Claiming error" to report the problem.


      diff --git a/website/templates/emails/collection_submission_cancel.html.mako b/website/templates/collection_submission_cancel.html.mako similarity index 51% rename from website/templates/emails/collection_submission_cancel.html.mako rename to website/templates/collection_submission_cancel.html.mako index 4818886f9a8..6ee11c1af7c 100644 --- a/website/templates/emails/collection_submission_cancel.html.mako +++ b/website/templates/collection_submission_cancel.html.mako @@ -1,36 +1,33 @@ <%inherit file="notify_base.mako" /> -<%! - from website import settings -%> <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      % if is_admin: - The request to add ${node.title} to - % if collection.provider: - ${collection.provider.name} + The request to add ${node_title} to + % if collection_provider: + ${collection_provider_name} % else: - ${collection.provider.name} + ${collection_provider_name} % endif was canceled. If you wish to be associated with the collection, you will need to request to be added again. % else: - ${remover.fullname} canceled the request to add - ${node.title}to - % if collection.provider: - ${collection.provider.name} + ${remover_fullname} canceled the request to add + ${node_title}to + % if collection_provider: + ${collection_provider_name} % else: - ${collection.provider.name} + ${collection_provider_name} % endif If you wish to be associated with the collection, an admin will need to request addition again. % endif

      - If you are not ${user.fullname} or you have been erroneously associated with - ${node.title}, email ${osf_contact_email} with the subject line + If you are not ${user_fullname} or you have been erroneously associated with + ${node_title}, email ${osf_contact_email} with the subject line "Claiming error" to report the problem.

      Sincerely,
      diff --git a/website/templates/emails/collection_submission_rejected.html.mako b/website/templates/collection_submission_rejected.html.mako similarity index 55% rename from website/templates/emails/collection_submission_rejected.html.mako rename to website/templates/collection_submission_rejected.html.mako index 48ad2da257d..2ebee15feef 100644 --- a/website/templates/emails/collection_submission_rejected.html.mako +++ b/website/templates/collection_submission_rejected.html.mako @@ -1,16 +1,13 @@ <%inherit file="notify_base.mako" /> -<%! - from website import settings -%> <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      % if is_admin: - Your request to add ${node.title} to - ${collection.provider.name} was not accepted. + Your request to add ${node_title} to + ${collection_provider_name} was not accepted.

      Rejection Justification:

      @@ -18,12 +15,12 @@ ${rejection_justification}

      % else: - ${node.title} was not accepted by ${collection.provider.name}. + ${node_title} was not accepted by ${collection_provider_name}. % endif

      - If you are not ${user.fullname} or you have been erroneously associated with - ${node.title}, email ${osf_contact_email} with the subject line + If you are not ${user_fullname} or you have been erroneously associated with + ${node_title}, email ${osf_contact_email} with the subject line "Claiming error" to report the problem.

      Sincerely,
      diff --git a/website/templates/emails/collection_submission_removed_admin.html.mako b/website/templates/collection_submission_removed_admin.html.mako similarity index 55% rename from website/templates/emails/collection_submission_removed_admin.html.mako rename to website/templates/collection_submission_removed_admin.html.mako index 81d8a545c07..bcc911ab66a 100644 --- a/website/templates/emails/collection_submission_removed_admin.html.mako +++ b/website/templates/collection_submission_removed_admin.html.mako @@ -1,26 +1,23 @@ <%inherit file="notify_base.mako" /> -<%! - from website import settings -%> <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      % if is_admin: - ${node.title} was removed from - ${collection.provider.name}. If you wish to be associated with the collection, you + ${node_title} was removed from + ${collection_provider_name}. If you wish to be associated with the collection, you will need to reapply to the collection again. % else: - ${remover.fullname} removed - ${node.title} from ${collection.provider.name}. + ${remover_fullname} removed + ${node_title} from ${collection_provider_name}. If you wish to be associated with the collection, an admin will need to reapply to the collection again. % endif

      - If you are not ${user.fullname} or you have been erroneously associated with - ${node.title}, email ${osf_contact_email} with the subject line + If you are not ${user_fullname} or you have been erroneously associated with + ${node_title}, email ${osf_contact_email} with the subject line "Claiming error" to report the problem.

      Sincerely,
      diff --git a/website/templates/emails/collection_submission_removed_moderator.html.mako b/website/templates/collection_submission_removed_moderator.html.mako similarity index 62% rename from website/templates/emails/collection_submission_removed_moderator.html.mako rename to website/templates/collection_submission_removed_moderator.html.mako index 70cb613e1af..48697d7694d 100644 --- a/website/templates/emails/collection_submission_removed_moderator.html.mako +++ b/website/templates/collection_submission_removed_moderator.html.mako @@ -1,15 +1,12 @@ <%inherit file="notify_base.mako" /> -<%! - from website import settings -%> <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      % if is_admin: - ${node.title} was removed by ${collection.provider.name}: + ${node_title} was removed by ${collection_provider_name}:

      ${rejection_justification} @@ -17,13 +14,13 @@ This can also be viewed in the collection status section on the project or component page. If you wish to be associated with the collection, you will need to reapply to the collection again. % else: - ${node.title} was removed by - ${collection.provider.name} + ${node_title} was removed by + ${collection_provider_name} % endif

      - If you are not ${user.fullname} or you have been erroneously associated with - ${node.title}, email ${osf_contact_email} with the subject line + If you are not ${user_fullname} or you have been erroneously associated with + ${node_title}, email ${osf_contact_email} with the subject line "Claiming error" to report the problem.

      Sincerely,
      diff --git a/website/templates/emails/collection_submission_removed_private.html.mako b/website/templates/collection_submission_removed_private.html.mako similarity index 51% rename from website/templates/emails/collection_submission_removed_private.html.mako rename to website/templates/collection_submission_removed_private.html.mako index eeac5728f30..ef6a18ad836 100644 --- a/website/templates/emails/collection_submission_removed_private.html.mako +++ b/website/templates/collection_submission_removed_private.html.mako @@ -1,36 +1,33 @@ <%inherit file="notify_base.mako" /> -<%! - from website import settings -%> <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      % if is_admin: - You have changed the privacy of ${node.title} and it has therefore been + You have changed the privacy of ${node_title} and it has therefore been removed from - % if collection.provider: - ${collection.provider.name} + % if collection_provider: + ${collection_provider_name} % else: - ${collection.provider.name} + ${collection_provider_name} % endif . If you wish to be associated with the collection, you will need to request addition to the collection again. % else: - ${remover.fullname} has changed the privacy settings for - ${node.title} it has therefore been removed from - % if collection.provider: - ${collection.provider.name} + ${remover_fullname} has changed the privacy settings for + ${node_title} it has therefore been removed from + % if collection_provider: + ${collection_provider_name} % else: - ${collection.provider.name} + ${collection_provider_name} % endif It will need to be re-submitteds to be included in the collection again. % endif

      - If you are not ${user.fullname} or you have been erroneously associated with - ${node.title}, email ${osf_contact_email} with the subject line + If you are not ${user_fullname} or you have been erroneously associated with + ${node_title}, email ${osf_contact_email} with the subject line "Claiming error" to report the problem.

      Sincerely,
      diff --git a/website/templates/emails/collection_submission_submitted.html.mako b/website/templates/collection_submission_submitted.html.mako similarity index 59% rename from website/templates/emails/collection_submission_submitted.html.mako rename to website/templates/collection_submission_submitted.html.mako index ce774878aa0..548fcaea33f 100644 --- a/website/templates/emails/collection_submission_submitted.html.mako +++ b/website/templates/collection_submission_submitted.html.mako @@ -1,25 +1,22 @@ <%inherit file="notify_base.mako" /> -<%! - from website import settings -%> <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      - % if is_initator: - You just started a request to add ${node.title} - to ${collection.provider.name}. + % if is_initiator: + You just started a request to add ${node_title} + to ${collection_provider_name}. All admins and contributors will be notified via email. % elif is_registered_contrib: - ${submitter.fullname} just included you in ${node.title} to a request to add - ${node.title} to - ${collection.provider.name}. + ${submitter_fullname} just included you in ${node_title} to a request to add + ${node_title} to + ${collection_provider_name}. All admins and contributors will be notified via email. % else: - ${submitter.fullname} included you in a request to add - ${node.title} to ${collection.provider.name} + ${submitter_fullname} included you in a request to add + ${node_title} to ${collection_provider_name} Click here to claim account link. After you set a password, you will be able to make contributions to the project. You will also be able to easily access this and any other project or component by going to your "My Projects" page. If you decide to not make an account, then it's important diff --git a/website/templates/emails/confirm.html.mako b/website/templates/confirm.html.mako similarity index 92% rename from website/templates/emails/confirm.html.mako rename to website/templates/confirm.html.mako index 8deed6c493f..2b10fc8fcdf 100644 --- a/website/templates/emails/confirm.html.mako +++ b/website/templates/confirm.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      This email address has been added to an account on the Open Science Framework.

      diff --git a/website/templates/emails/confirm_erpc.html.mako b/website/templates/confirm_erpc.html.mako similarity index 92% rename from website/templates/emails/confirm_erpc.html.mako rename to website/templates/confirm_erpc.html.mako index dd88fde5038..359260cb7f6 100644 --- a/website/templates/emails/confirm_erpc.html.mako +++ b/website/templates/confirm_erpc.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      Welcome to the Open Science Framework and the Election Research Preacceptance Competition. To continue, please verify your email address by visiting this link:

      diff --git a/website/templates/emails/confirm_merge.html.mako b/website/templates/confirm_merge.html.mako similarity index 76% rename from website/templates/emails/confirm_merge.html.mako rename to website/templates/confirm_merge.html.mako index e62e142d5a5..77e61eada61 100644 --- a/website/templates/emails/confirm_merge.html.mako +++ b/website/templates/confirm_merge.html.mako @@ -3,11 +3,11 @@ <%def name="content()"> - Hello ${merge_target.fullname},
      + Hello ${merge_target_fullname},

      - This email is to notify you that ${user.username} has initiated an account merge with your account on the Open Science Framework (OSF). This merge will move all of the projects and components associated with ${email} and with ${user.username} into one account. All projects and components will be displayed under ${user.username}.
      + This email is to notify you that ${user_username} has initiated an account merge with your account on the Open Science Framework (OSF). This merge will move all of the projects and components associated with ${email} and with ${user_username} into one account. All projects and components will be displayed under ${user.username}.

      - Both ${user.username} and ${email} can be used to log into the account. However, ${email} will no longer show up in user search.
      + Both ${user_username} and ${email} can be used to log into the account. However, ${email} will no longer show up in user search.

      This action is irreversible. To confirm this account merge, click this link: ${confirmation_url}.

      diff --git a/website/templates/emails/confirm_moderation.html.mako b/website/templates/confirm_moderation.html.mako similarity index 60% rename from website/templates/emails/confirm_moderation.html.mako rename to website/templates/confirm_moderation.html.mako index 5e1c74bbe06..2cf511d7115 100644 --- a/website/templates/emails/confirm_moderation.html.mako +++ b/website/templates/confirm_moderation.html.mako @@ -3,17 +3,17 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      - You have been added by ${referrer.fullname}, as ${'an administrator' if is_admin else 'a moderator'} to ${provider.name}, powered by OSF. To set a password for your account, visit:
      + You have been added by ${referrer_fullname}, as ${'an administrator' if is_admin else 'a moderator'} to ${provider_name}, powered by OSF. To set a password for your account, visit:

      ${claim_url}

      - Once you have set a password you will be able to moderate, create and approve your own submissions. You will automatically be subscribed to notification emails for new submissions to ${provider.name}.
      + Once you have set a password you will be able to moderate, create and approve your own submissions. You will automatically be subscribed to notification emails for new submissions to ${provider_name}.

      - If you are not ${user.fullname} or you have been erroneously associated with ${provider.name}, email contact+${provider._id}@osf.io with the subject line "Claiming error" to report the problem.
      + If you are not ${user_fullname} or you have been erroneously associated with ${provider_name}, email contact+${provider__id}@osf.io with the subject line "Claiming error" to report the problem.

      Sincerely,
      - Your ${provider.name} and OSF teams
      + Your ${provider_name} and OSF teams
      diff --git a/website/templates/emails/confirm_preprints_branded.html.mako b/website/templates/confirm_preprints_branded.html.mako similarity index 93% rename from website/templates/emails/confirm_preprints_branded.html.mako rename to website/templates/confirm_preprints_branded.html.mako index f24befd5ea1..b1a489cc273 100644 --- a/website/templates/emails/confirm_preprints_branded.html.mako +++ b/website/templates/confirm_preprints_branded.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      Welcome to ${branded_preprints_provider}, powered by the Open Science Framework. To continue, please verify your email address by visiting this link:

      diff --git a/website/templates/emails/confirm_preprints_osf.html.mako b/website/templates/confirm_preprints_osf.html.mako similarity index 92% rename from website/templates/emails/confirm_preprints_osf.html.mako rename to website/templates/confirm_preprints_osf.html.mako index 179bc6f8559..25df4ea3b47 100644 --- a/website/templates/emails/confirm_preprints_osf.html.mako +++ b/website/templates/confirm_preprints_osf.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      Welcome to the Open Science Framework and OSF Preprints. To continue, please verify your email address by visiting this link:

      diff --git a/website/templates/emails/confirm_registries_osf.html.mako b/website/templates/confirm_registries_osf.html.mako similarity index 92% rename from website/templates/emails/confirm_registries_osf.html.mako rename to website/templates/confirm_registries_osf.html.mako index 3ec7845ae9f..61d1da4f6f5 100644 --- a/website/templates/emails/confirm_registries_osf.html.mako +++ b/website/templates/confirm_registries_osf.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      Welcome to the Open Science Framework and OSF Registries. To continue, please verify your email address by visiting this link:

      diff --git a/website/templates/emails/contributor_added_access_request.html.mako b/website/templates/contributor_added_access_request.html.mako similarity index 57% rename from website/templates/emails/contributor_added_access_request.html.mako rename to website/templates/contributor_added_access_request.html.mako index 6a0ea8f6b9a..deaa626091b 100644 --- a/website/templates/emails/contributor_added_access_request.html.mako +++ b/website/templates/contributor_added_access_request.html.mako @@ -3,14 +3,9 @@ <%def name="content()"> - <%! - from website import settings - %> - Hello ${user.fullname},
      + Hello ${user_fullname},

      - ${referrer_name + ' has approved your access request and added you' if referrer_name else 'Your access request has been approved, and you have been added'} as a contributor to the project "${node.title}" on OSF.
      -
      - You will ${'not receive ' if all_global_subscriptions_none else 'be automatically subscribed to '} notification emails for this project. To change your email notification preferences, visit your project or your user settings.
      + ${referrer_name + ' has approved your access request and added you' if referrer_name else 'Your access request has been approved, and you have been added'} as a contributor to the project "${node_title}" on OSF.

      Sincerely,

      diff --git a/website/templates/contributor_added_default.html.mako b/website/templates/contributor_added_default.html.mako new file mode 100644 index 00000000000..31cd8cb2c2c --- /dev/null +++ b/website/templates/contributor_added_default.html.mako @@ -0,0 +1,21 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + Hello ${user_fullname},
      +
      + ${referrer_text} as a contributor to the project "${node_title}" on the Open Science Framework: ${node_absolute_url}
      +
      + If you are erroneously being associated with "${node_title}," then you may visit the project's "Contributors" page and remove yourself as a contributor.
      +
      + Sincerely,
      +
      + Open Science Framework Robot
      +
      + Want more information? Visit https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      +
      + Questions? Email ${osf_contact_email}
      + + + diff --git a/website/templates/emails/contributor_added_draft_registration.html.mako b/website/templates/contributor_added_draft_registration.html.mako similarity index 51% rename from website/templates/emails/contributor_added_draft_registration.html.mako rename to website/templates/contributor_added_draft_registration.html.mako index 6cbd40ece83..a7140c91eeb 100644 --- a/website/templates/emails/contributor_added_draft_registration.html.mako +++ b/website/templates/contributor_added_draft_registration.html.mako @@ -3,24 +3,21 @@ <%def name="content()"> - <%! - from website import settings - %> - Hello ${user.fullname}, + Hello ${user_fullname},

      - ${'You just started' if not referrer_name else referrer_name + ' has added you as a contributor on'} - % if not node.title or node.title == 'Untitled': - a new registration draft + ${referrer_text} + % if not node_title or node_title == 'Untitled': + a new registration draft % else: - a new registration draft titled ${node.title} + a new registration draft titled ${node_title} % endif to be submitted for inclusion in the - ${node.provider.name if node.provider else "OSF Registry"}. + ${registry_text}.

      - You can access this draft by going to your "My Registrations" page. + You can access this draft by going to your "My Registrations" page.

      - % if node.has_permission(user, 'admin'): + % if node_has_permission_admin:

      Each contributor that is added will be notified via email, which will contain a link to the draft registration.

      @@ -37,7 +34,7 @@ The OSF Team

      - Want more information? Visit ${settings.DOMAIN} to learn about the OSF, + Want more information? Visit ${domain} to learn about the OSF, or https://cos.io/ for information about its supporting organization, the Center for Open Science.

      diff --git a/website/templates/contributor_added_preprint_node_from_osf.html.mako b/website/templates/contributor_added_preprint_node_from_osf.html.mako new file mode 100644 index 00000000000..0dd6ff1d035 --- /dev/null +++ b/website/templates/contributor_added_preprint_node_from_osf.html.mako @@ -0,0 +1,27 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + Hello ${user_fullname},
      +
      + ${referrer_text} as a contributor to the project "${node_title}" on the Open Science Framework: ${node_absolute_url}
      +
      + This project also contains the supplemental files for the following preprint(s): +
      +
        + ${preprint_list} +
      +
      + If you are erroneously being associated with "${node_title}," then you may visit the project's "Contributors" page and remove yourself as a contributor.
      +
      + Sincerely,
      +
      + Open Science Framework Robot
      +
      + Want more information? Visit https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      +
      + Questions? Email ${osf_contact_email}
      + + + diff --git a/website/templates/contributor_added_preprints.html.mako b/website/templates/contributor_added_preprints.html.mako new file mode 100644 index 00000000000..86d62b5187c --- /dev/null +++ b/website/templates/contributor_added_preprints.html.mako @@ -0,0 +1,21 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + Hello ${user_fullname},
      +
      + ${referrer_text} as a contributor to the ${branded_service_preprint_word} "${node_title}" on ${branded_service_name}, which is hosted on the Open Science Framework: ${node_absolute_url}
      +
      + If you have been erroneously associated with "${node_title}", then you may visit the ${branded_service_preprint_word} and remove yourself as a contributor.
      +
      + Sincerely,
      +
      + Your ${branded_service_name} and OSF teams
      +
      + Want more information? Visit https://osf.io/preprints/${branded_service__id} to learn about ${branded_service_name} or https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      +
      + Questions? Email support+${branded_service__id}@osf.io
      + + + diff --git a/website/templates/contributor_added_preprints_osf.html.mako b/website/templates/contributor_added_preprints_osf.html.mako new file mode 100644 index 00000000000..79b0b080675 --- /dev/null +++ b/website/templates/contributor_added_preprints_osf.html.mako @@ -0,0 +1,21 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + + Hello ${user_fullname},
      +
      + ${referrer_text}} as a contributor to the preprint "${node_title}" on the Open Science Framework: ${node_absolute_url}
      +
      + If you are erroneously being associated with "${node_title}," then you may visit the preprint and remove yourself as a contributor.
      +
      + Sincerely,
      +
      + Open Science Framework Robot
      +
      + Want more information? Visit https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      +
      + Questions? Email ${osf_contact_email}
      + + + diff --git a/website/templates/emails/crossref_doi_error.html.mako b/website/templates/crossref_doi_error.html.mako similarity index 100% rename from website/templates/emails/crossref_doi_error.html.mako rename to website/templates/crossref_doi_error.html.mako diff --git a/website/templates/emails/crossref_doi_pending.html.mako b/website/templates/crossref_doi_pending.html.mako similarity index 100% rename from website/templates/emails/crossref_doi_pending.html.mako rename to website/templates/crossref_doi_pending.html.mako diff --git a/website/templates/digest.html.mako b/website/templates/digest.html.mako new file mode 100644 index 00000000000..b4218ba9775 --- /dev/null +++ b/website/templates/digest.html.mako @@ -0,0 +1,30 @@ +<%inherit file="notify_base.mako" /> + +<%def name="content()"> + + +

      + Recent Activity +

      + + + + + % if notifications: + + + % for n in notifications: + + + + % endfor + +
      + ${n} +
      + % else: +

      No recent activity.

      + % endif + + + diff --git a/website/templates/emails/digest_reviews_moderators.html.mako b/website/templates/digest_reviews_moderators.html.mako similarity index 100% rename from website/templates/emails/digest_reviews_moderators.html.mako rename to website/templates/digest_reviews_moderators.html.mako diff --git a/website/templates/emails/duplicate_accounts_sso_osf4i.html.mako b/website/templates/duplicate_accounts_sso_osf4i.html.mako similarity index 64% rename from website/templates/emails/duplicate_accounts_sso_osf4i.html.mako rename to website/templates/duplicate_accounts_sso_osf4i.html.mako index 8577bdb4aa0..3699735a6a3 100644 --- a/website/templates/emails/duplicate_accounts_sso_osf4i.html.mako +++ b/website/templates/duplicate_accounts_sso_osf4i.html.mako @@ -3,13 +3,13 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      - Thank you for connecting to OSF through your institution. We have found two OSF accounts associated with your institutional identity: <${user.username}>(${user._id}) and <${duplicate_user.username}>(${duplicate_user._id}). We have made <${user.username}> the account primarily associated with your institution.
      + Thank you for connecting to OSF through your institution. We have found two OSF accounts associated with your institutional identity: <${user_username}>(${user__id}) and <${duplicate_user_username}>(${duplicate_user__id}). We have made <${user_username}> the account primarily associated with your institution.

      - If <${duplicate_user.username}> is also your account, we would encourage you to merge it into your primary account. Instructions for merging your accounts can be found at: Merge Your Accounts. This action will move all projects and components associated with <${duplicate_user.username}> into the <${user.username}> account.
      + If <${duplicate_user_username}> is also your account, we would encourage you to merge it into your primary account. Instructions for merging your accounts can be found at: Merge Your Accounts. This action will move all projects and components associated with <${duplicate_user_username}> into the <${user_username}> account.

      - If you want to keep <${duplicate_user.username}> separate from <${user.username}> you will need to log into that account with your email and OSF password instead of the institutional authentication.
      + If you want to keep <${duplicate_user_username}> separate from <${user_username}> you will need to log into that account with your email and OSF password instead of the institutional authentication.

      If you have any issues, questions or need our help, contact ${osf_support_email} and we will be happy to assist.

      diff --git a/website/templates/emails/contributor_added_default.html.mako b/website/templates/emails/contributor_added_default.html.mako deleted file mode 100644 index 343169f164a..00000000000 --- a/website/templates/emails/contributor_added_default.html.mako +++ /dev/null @@ -1,26 +0,0 @@ -<%inherit file="notify_base.mako" /> - -<%def name="content()"> - - - <%! - from website import settings - %> - Hello ${user.fullname},
      -
      - ${referrer_name + ' has added you' if referrer_name else 'You have been added'} as a contributor to the project "${node.title}" on the Open Science Framework: ${node.absolute_url}
      -
      - You will ${'not receive ' if all_global_subscriptions_none else 'be automatically subscribed to '}notification emails for this project. To change your email notification preferences, visit your project or your user settings: ${settings.DOMAIN + "settings/notifications/"}
      -
      - If you are erroneously being associated with "${node.title}," then you may visit the project's "Contributors" page and remove yourself as a contributor.
      -
      - Sincerely,
      -
      - Open Science Framework Robot
      -
      - Want more information? Visit https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      -
      - Questions? Email ${osf_contact_email}
      - - - diff --git a/website/templates/emails/contributor_added_preprint_node_from_osf.html.mako b/website/templates/emails/contributor_added_preprint_node_from_osf.html.mako deleted file mode 100644 index bf7ed321732..00000000000 --- a/website/templates/emails/contributor_added_preprint_node_from_osf.html.mako +++ /dev/null @@ -1,34 +0,0 @@ -<%inherit file="notify_base.mako" /> - -<%def name="content()"> - - - <%! - from website import settings - %> - Hello ${user.fullname},
      -
      - ${referrer_name + ' has added you' if referrer_name else 'You have been added'} as a contributor to the project "${node.title}" on the Open Science Framework: ${node.absolute_url}
      -
      - This project also contains the supplemental files for the following preprint(s): -
      -
        - % for preprint in published_preprints: -
      • ${preprint['absolute_url']}
      • - % endfor -
      -
      - You will ${'not receive ' if all_global_subscriptions_none else 'be automatically subscribed to '}notification emails for this project. To change your email notification preferences, visit your project or your user settings: ${settings.DOMAIN + "settings/notifications/"}
      -
      - If you are erroneously being associated with "${node.title}," then you may visit the project's "Contributors" page and remove yourself as a contributor.
      -
      - Sincerely,
      -
      - Open Science Framework Robot
      -
      - Want more information? Visit https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      -
      - Questions? Email ${osf_contact_email}
      - - - diff --git a/website/templates/emails/contributor_added_preprints.html.mako b/website/templates/emails/contributor_added_preprints.html.mako deleted file mode 100644 index e4c61b22937..00000000000 --- a/website/templates/emails/contributor_added_preprints.html.mako +++ /dev/null @@ -1,26 +0,0 @@ -<%inherit file="notify_base.mako" /> - -<%def name="content()"> - - - <%! - from website import settings - %> - Hello ${user.fullname},
      -
      - ${referrer_name + ' has added you' if referrer_name else 'You have been added'} as a contributor to the ${branded_service.preprint_word} "${node.title}" on ${branded_service.name}, which is hosted on the Open Science Framework: ${node.absolute_url}
      -
      - You will ${'not receive ' if all_global_subscriptions_none else 'be automatically subscribed to '}notification emails for this ${branded_service.preprint_word}. To change your email notification preferences, visit your user settings: ${settings.DOMAIN + "settings/notifications/"}
      -
      - If you have been erroneously associated with "${node.title}", then you may visit the ${branded_service.preprint_word} and remove yourself as a contributor.
      -
      - Sincerely,
      -
      - Your ${branded_service.name} and OSF teams
      -
      - Want more information? Visit https://osf.io/preprints/${branded_service._id} to learn about ${branded_service.name} or https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      -
      - Questions? Email support+${branded_service._id}@osf.io
      - - - diff --git a/website/templates/emails/contributor_added_preprints_osf.html.mako b/website/templates/emails/contributor_added_preprints_osf.html.mako deleted file mode 100644 index eadbe996a91..00000000000 --- a/website/templates/emails/contributor_added_preprints_osf.html.mako +++ /dev/null @@ -1,26 +0,0 @@ -<%inherit file="notify_base.mako" /> - -<%def name="content()"> - - - <%! - from website import settings - %> - Hello ${user.fullname},
      -
      - ${referrer_name + ' has added you' if referrer_name else 'You have been added'} as a contributor to the preprint "${node.title}" on the Open Science Framework: ${node.absolute_url}
      -
      - You will ${'not receive ' if all_global_subscriptions_none else 'be automatically subscribed to '}notification emails for this preprint. To change your email notification preferences, visit your user settings: ${settings.DOMAIN + "settings/notifications/"}
      -
      - If you are erroneously being associated with "${node.title}," then you may visit the preprint and remove yourself as a contributor.
      -
      - Sincerely,
      -
      - Open Science Framework Robot
      -
      - Want more information? Visit https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      -
      - Questions? Email ${osf_contact_email}
      - - - diff --git a/website/templates/emails/digest.html.mako b/website/templates/emails/digest.html.mako deleted file mode 100644 index 719a86b0c3c..00000000000 --- a/website/templates/emails/digest.html.mako +++ /dev/null @@ -1,47 +0,0 @@ -<%inherit file="notify_base.mako" /> - -<% from website import util %> -<%def name="build_message(d, parent=None)"> -%for key in d['children']: - %if d['children'][key]['messages']: - - - - - - - - - -
      -

      - <% from osf.models import Guid %> - ${Guid.load(key).referent.title} - %if parent : - in ${Guid.objects.get(_id=parent).referent.title} - %endif -

      -
      - %for m in d['children'][key]['messages']: - ${m} - %endfor -
      - %endif - %if isinstance(d['children'][key]['children'], dict): - ${build_message(d['children'][key], key )} - %endif -%endfor - - -<%def name="content()"> - - -

      Recent Activity

      - - - - - ${build_message(message)} - - - diff --git a/website/templates/emails/empty.html.mako b/website/templates/emails/empty.html.mako deleted file mode 100644 index c78480affe2..00000000000 --- a/website/templates/emails/empty.html.mako +++ /dev/null @@ -1 +0,0 @@ -

      ${body}

      diff --git a/website/templates/emails/global_mentions.html.mako b/website/templates/emails/global_mentions.html.mako deleted file mode 100644 index dd4aa4ea370..00000000000 --- a/website/templates/emails/global_mentions.html.mako +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - -
      avatar - ${user.fullname} - mentioned you in a comment on your ${provider + ' ' if page_type == 'file' else ''}${page_type} - %if page_type == 'file' or page_type == 'wiki': - ${page_title} - %endif - at ${localized_timestamp}: - ${content} -
      diff --git a/website/templates/emails/invite_preprints.html.mako b/website/templates/emails/invite_preprints.html.mako deleted file mode 100644 index 5a417a3c9b5..00000000000 --- a/website/templates/emails/invite_preprints.html.mako +++ /dev/null @@ -1,32 +0,0 @@ -<%inherit file="notify_base.mako" /> - -<%def name="content()"> - - - <%! - from website import settings - %> - Hello ${fullname},
      -
      - You have been added by ${referrer.fullname} as a contributor to the ${branded_service.preprint_word} "${node.title}" on ${branded_service.name}, powered by the Open Science Framework. To set a password for your account, visit:
      -
      - ${claim_url}
      -
      - Once you have set a password, you will be able to make contributions to "${node.title}" and create your own ${branded_service.preprint_word}. You will automatically be subscribed to notification emails for this ${branded_service.preprint_word}. To change your email notification preferences, visit your user settings: ${settings.DOMAIN + "settings/notifications/"}
      -
      - To preview "${node.title}" click the following link: ${node.absolute_url}
      -
      - (NOTE: if this preprint is unpublished, you will not be able to view it until you have confirmed your account)
      -
      - If you are not ${fullname} or you have been erroneously associated with "${node.title}", then email contact+${branded_service._id}@osf.io with the subject line "Claiming Error" to report the problem.
      -
      - Sincerely,
      -
      - Your ${branded_service.name} and OSF teams
      -
      - Want more information? Visit https://osf.io/preprints/${branded_service._id} to learn about ${branded_service.name} or https://osf.io/ to learn about the Open Science Framework, or https://cos.io/ for information about its supporting organization, the Center for Open Science.
      -
      - Questions? Email support+${branded_service._id}@osf.io
      - - - diff --git a/website/templates/emails/moderator_added.html.mako b/website/templates/emails/moderator_added.html.mako deleted file mode 100644 index 892d216c094..00000000000 --- a/website/templates/emails/moderator_added.html.mako +++ /dev/null @@ -1,18 +0,0 @@ -<%inherit file="notify_base.mako" /> - -<%def name="content()"> - - - Hello ${user.fullname},
      -
      - You have been added by ${referrer.fullname} as ${'an administrator' if is_admin else 'a moderator'} to ${provider.name}, powered by OSF.
      -
      - You will automatically be subscribed to notification emails for new submissions to ${provider.name}.
      -
      - If you are not ${user.fullname} or you have been erroneously associated with ${provider.name}, email contact+${provider._id}@osf.io with the subject line "Claiming Error" to report the problem.
      -
      - Sincerely,
      -
      - Your ${provider.name} and OSF teams
      - - diff --git a/website/templates/emails/new_public_project.html.mako b/website/templates/emails/new_public_project.html.mako deleted file mode 100644 index ea4ac45b88a..00000000000 --- a/website/templates/emails/new_public_project.html.mako +++ /dev/null @@ -1,31 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="notify_base.mako"/> -<%def name="content()"> -
      -
      - Hello ${fullname}, -

      - Congratulations on making a public project on OSF! Now that your project “${project_title}" is public, you’ll be able to take advantage of more OSF features: - - -
      - -
      - If you would like to learn more about how to take advantage of any of these features, visit our Guides.. -

      - Sincerely, -
      - The OSF Team - -
      - -<%def name="footer()"> -
      - The OSF is provided as a free, open source service from the Center for Open Science. - diff --git a/website/templates/emails/notify_base.mako b/website/templates/emails/notify_base.mako deleted file mode 100644 index 10e81d98840..00000000000 --- a/website/templates/emails/notify_base.mako +++ /dev/null @@ -1,103 +0,0 @@ -<%! - from website import settings - from datetime import datetime -%> - - - - - - - - - - - - - - - - - - - - -
      - - - - - - -
      -
      - - - ${self.content()} - -
      - % if context.get('can_change_preferences', True): - - - - - - -
      - % if context.get('is_reviews_moderator_notification', False): -

      - % if not context.get('referrer', False): - You are receiving these emails because you are ${'an administrator' if is_admin else 'a moderator'} on ${provider_name}. - % endif - To change your moderation notification preferences, - visit your notification settings. -

      - % else: -

      To change how often you receive emails, visit - % if context.get('can_change_node_preferences', False) and node: - this project's settings for emails about this project or - % endif - your user settings to manage default email settings. -

      - % endif -
      - % endif -
      - - - - - - - -
      - - - -<%def name="content()"> - - - -<%def name="footer()"> - - diff --git a/website/templates/emails/reviews_submission_confirmation.html.mako b/website/templates/emails/reviews_submission_confirmation.html.mako deleted file mode 100644 index bd541714347..00000000000 --- a/website/templates/emails/reviews_submission_confirmation.html.mako +++ /dev/null @@ -1,111 +0,0 @@ -## -*- coding: utf-8 -*- -<%inherit file="notify_base.mako"/> -<%def name="content()"> - <% from website import settings %> - <% - isOsfSubmission = reviewable.provider.name == 'Open Science Framework' - if isOsfSubmission: - reviewable.provider.name = 'OSF Preprints' - %> - - - % if document_type == 'registration': -
      - Hello ${user.fullname}, -

      - Your ${document_type} ${reviewable.title} has been successfully submitted to ${reviewable.provider.name}. -

      - ${reviewable.provider.name} has chosen to moderate their submissions using a pre-moderation workflow, which means your submission is pending until accepted by a moderator. -

      - You will receive a separate notification informing you of any status changes. -

      - Learn more about ${reviewable.provider.name} or OSF. -

      - Sincerely, - The ${reviewable.provider.name} and OSF teams. -

      - % else: -
      -

      Hello ${user.fullname},

      - % if is_creator: -

      - Your ${document_type} - ${reviewable.title} - has been successfully submitted to ${reviewable.provider.name}. -

      - % else: -

      - ${referrer.fullname} has added you as a contributor to the - ${document_type} - ${reviewable.title} - on ${reviewable.provider.name}, which is hosted on the OSF. -

      - % endif -

      - % if workflow == 'pre-moderation': - ${reviewable.provider.name} has chosen to moderate their submissions using a pre-moderation workflow, - which means your submission is pending until accepted by a moderator. - % elif workflow == 'post-moderation': - ${reviewable.provider.name} has chosen to moderate their submissions using a - post-moderation workflow, which means your submission is public and discoverable, - while still pending acceptance by a moderator. - % else: - - - - - - -
      - Now that you've shared your ${document_type}, take advantage of more OSF features: -
        -
      • Upload supplemental, materials, data, and code to an OSF project associated with your ${document_type}. - Learn how
      • -
      • Preregister your next study. Read more
      • -
      • Or share on social media: Tell your friends through: - - - - - - - - -
        - - twitter - - - - facebook - - - - LinkedIn - -
        -
      • -
      -
      - % endif - % if not no_future_emails and not isOsfSubmission: - You will receive a separate notification informing you of any status changes. - % endif -

      - % if not is_creator: -

      - If you have been erroneously associated with "${reviewable.title}," then you may visit the ${document_type} - and remove yourself as a contributor. -

      - % endif -

      Learn more about ${reviewable.provider.name} or OSF.

      -
      -

      - Sincerely,
      - ${'The OSF team' if isOsfSubmission else 'The {provider} and OSF teams'.format(provider=reviewable.provider.name)} -

      -
      - % endif - - - diff --git a/website/templates/emails/test.html.mako b/website/templates/emails/test.html.mako deleted file mode 100644 index da55c3f3af8..00000000000 --- a/website/templates/emails/test.html.mako +++ /dev/null @@ -1 +0,0 @@ -Hello

      ${name}

      diff --git a/website/templates/emails/external_confirm_create.html.mako b/website/templates/external_confirm_create.html.mako similarity index 93% rename from website/templates/emails/external_confirm_create.html.mako rename to website/templates/external_confirm_create.html.mako index 75004788259..9063a773504 100644 --- a/website/templates/emails/external_confirm_create.html.mako +++ b/website/templates/external_confirm_create.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      Thank you for registering with ${external_id_provider} for an account on the Open Science Framework. We will add ${external_id_provider} to your OSF profile.

      diff --git a/website/templates/emails/external_confirm_link.html.mako b/website/templates/external_confirm_link.html.mako similarity index 93% rename from website/templates/emails/external_confirm_link.html.mako rename to website/templates/external_confirm_link.html.mako index 6b0b9ed4d27..13c178de65e 100644 --- a/website/templates/emails/external_confirm_link.html.mako +++ b/website/templates/external_confirm_link.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      Thank you for linking your ${external_id_provider} account to the Open Science Framework. We will add ${external_id_provider} to your OSF profile.

      diff --git a/website/templates/emails/external_confirm_success.html.mako b/website/templates/external_confirm_success.html.mako similarity index 90% rename from website/templates/emails/external_confirm_success.html.mako rename to website/templates/external_confirm_success.html.mako index 99365f6edd6..b487f64fb6d 100644 --- a/website/templates/emails/external_confirm_success.html.mako +++ b/website/templates/external_confirm_success.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      Congratulations! You have successfully linked your ${external_id_provider} account to the Open Science Framework (OSF).

      diff --git a/website/templates/emails/file_operation_failed.html.mako b/website/templates/file_operation_failed.html.mako similarity index 81% rename from website/templates/emails/file_operation_failed.html.mako rename to website/templates/file_operation_failed.html.mako index 36c6fb0700c..4fc347b31cd 100644 --- a/website/templates/emails/file_operation_failed.html.mako +++ b/website/templates/file_operation_failed.html.mako @@ -1,8 +1,4 @@ -<%! - from website import settings - from datetime import datetime -%> @@ -23,10 +19,10 @@ @@ -49,9 +45,9 @@ @@ -60,7 +56,7 @@ @@ -74,7 +70,7 @@ diff --git a/website/templates/emails/file_operation_success.html.mako b/website/templates/file_operation_success.html.mako similarity index 80% rename from website/templates/emails/file_operation_success.html.mako rename to website/templates/file_operation_success.html.mako index 10792e8c6f1..178e20d6439 100644 --- a/website/templates/emails/file_operation_success.html.mako +++ b/website/templates/file_operation_success.html.mako @@ -1,8 +1,4 @@ -<%! - from website import settings - from datetime import datetime -%> @@ -23,10 +19,10 @@ @@ -49,9 +45,9 @@ @@ -60,7 +56,7 @@ @@ -73,7 +69,7 @@ diff --git a/website/templates/emails/file_updated.html.mako b/website/templates/file_updated.html.mako similarity index 98% rename from website/templates/emails/file_updated.html.mako rename to website/templates/file_updated.html.mako index 6eae7990125..4701640013d 100644 --- a/website/templates/emails/file_updated.html.mako +++ b/website/templates/file_updated.html.mako @@ -4,7 +4,7 @@ diff --git a/website/templates/emails/forgot_password.html.mako b/website/templates/forgot_password.html.mako similarity index 100% rename from website/templates/emails/forgot_password.html.mako rename to website/templates/forgot_password.html.mako diff --git a/website/templates/emails/forgot_password_institution.html.mako b/website/templates/forgot_password_institution.html.mako similarity index 100% rename from website/templates/emails/forgot_password_institution.html.mako rename to website/templates/forgot_password_institution.html.mako diff --git a/website/templates/emails/fork_completed.html.mako b/website/templates/fork_completed.html.mako similarity index 59% rename from website/templates/emails/fork_completed.html.mako rename to website/templates/fork_completed.html.mako index e18c3479a56..6fd6ef83253 100644 --- a/website/templates/emails/fork_completed.html.mako +++ b/website/templates/fork_completed.html.mako @@ -2,11 +2,10 @@ <%def name="content()"> -<% from website import settings %> diff --git a/website/templates/emails/fork_failed.html.mako b/website/templates/fork_failed.html.mako similarity index 52% rename from website/templates/emails/fork_failed.html.mako rename to website/templates/fork_failed.html.mako index 4036689501c..8a66994f963 100644 --- a/website/templates/emails/fork_failed.html.mako +++ b/website/templates/fork_failed.html.mako @@ -2,12 +2,11 @@ <%def name="content()"> -<% from website import settings %> diff --git a/website/templates/emails/forward_invite.html.mako b/website/templates/forward_invite.html.mako similarity index 52% rename from website/templates/emails/forward_invite.html.mako rename to website/templates/forward_invite.html.mako index d580a906354..1b9044f8ce6 100644 --- a/website/templates/emails/forward_invite.html.mako +++ b/website/templates/forward_invite.html.mako @@ -3,28 +3,25 @@ <%def name="content()"> + + diff --git a/website/templates/emails/invite_preprints_osf.html.mako b/website/templates/invite_preprints_osf.html.mako similarity index 59% rename from website/templates/emails/invite_preprints_osf.html.mako rename to website/templates/invite_preprints_osf.html.mako index 36bd9528d47..253c5ce5f0e 100644 --- a/website/templates/emails/invite_preprints_osf.html.mako +++ b/website/templates/invite_preprints_osf.html.mako @@ -3,22 +3,19 @@ <%def name="content()"> + + diff --git a/website/templates/emails/new_pending_submissions.html.mako b/website/templates/new_pending_submissions.html.mako similarity index 92% rename from website/templates/emails/new_pending_submissions.html.mako rename to website/templates/new_pending_submissions.html.mako index 12208b272fe..46f6094276b 100644 --- a/website/templates/emails/new_pending_submissions.html.mako +++ b/website/templates/new_pending_submissions.html.mako @@ -5,9 +5,9 @@ At ${localized_timestamp}: % if is_request_email: - ${requester.fullname} + ${requester_fullname} % else: - ${', '.join(reviewable.contributors.values_list('fullname', flat=True))} + ${requester_contributor_names} % endif ${message} diff --git a/website/templates/emails/new_pending_withdraw_requests.html.mako b/website/templates/new_pending_withdraw_requests.html.mako similarity index 100% rename from website/templates/emails/new_pending_withdraw_requests.html.mako rename to website/templates/new_pending_withdraw_requests.html.mako diff --git a/website/templates/new_public_project.html.mako b/website/templates/new_public_project.html.mako new file mode 100644 index 00000000000..94e19221df0 --- /dev/null +++ b/website/templates/new_public_project.html.mako @@ -0,0 +1,27 @@ +## -*- coding: utf-8 -*- +<%inherit file="notify_base.mako" /> +<%def name="content()"> +
      +
      + Hello ${user_fullname}, +

      + Congratulations on making a public project on OSF! Now that your project “${project_title}" is public, you’ll be able to take advantage of more OSF features: + +
      +
      + If you would like to learn more about how to take advantage of any of these features, visit our Guides.. +

      + Sincerely, +
      + The OSF Team + +
      + +<%def name="footer()"> +
      + The OSF is provided as a free, open source service from the Center for Open Science. + diff --git a/website/templates/emails/no_addon.html.mako b/website/templates/no_addon.html.mako similarity index 69% rename from website/templates/emails/no_addon.html.mako rename to website/templates/no_addon.html.mako index f8ccd89f7d8..1009e18eb91 100644 --- a/website/templates/emails/no_addon.html.mako +++ b/website/templates/no_addon.html.mako @@ -1,9 +1,9 @@ ## -*- coding: utf-8 -*- -<%inherit file="notify_base.mako"/> +<%inherit file="notify_base.mako" /> <%def name="content()">

      - Hello ${fullname}, + Hello ${user_fullname},

      Do you use storage services like Dropbox, GitHub, or Google Drive to keep track of your research materials? The Open Science Framework (OSF) makes it easy to integrate various research tools you already use by allowing @@ -11,7 +11,7 @@ OSF or external storage services. Files will be synced whenever you make changes. Get more information on OSF add-ons.

      - Link your accounts today: https://osf.io/settings. + Link your accounts today: https://osf.io/settings.

      Best wishes,
      COS Support Team @@ -20,5 +20,5 @@ <%def name="footer()">
      - The Open Science Framework is provided as a free, open source service from the Center for Open Science. + The Open Science Framework is provided as a free, open source service from the Center for Open Science. diff --git a/website/templates/emails/no_login.html.mako b/website/templates/no_login.html.mako similarity index 85% rename from website/templates/emails/no_login.html.mako rename to website/templates/no_login.html.mako index d691030bed6..8c4f44f4d30 100644 --- a/website/templates/emails/no_login.html.mako +++ b/website/templates/no_login.html.mako @@ -1,9 +1,9 @@ ## -*- coding: utf-8 -*- -<%inherit file="notify_base.mako"/> +<%inherit file="notify_base.mako" /> <%def name="content()">

      - Hello ${fullname}, + Hello ${user_fullname},

      We’ve noticed it’s been a while since you used the OSF. We are constantly adding and improving features, so we thought it might be time to check in with you. Most researchers begin using the OSF by creating a project to organize their files and notes. Projects are equipped with powerful features to help you manage your research: @@ -24,5 +24,5 @@ <%def name="footer()">
      - The OSF is provided as a free, open source service from the Center for Open Science. + The OSF is provided as a free, open source service from the Center for Open Science. diff --git a/website/templates/emails/node_request_institutional_access_request.html.mako b/website/templates/node_request_institutional_access_request.html.mako similarity index 59% rename from website/templates/emails/node_request_institutional_access_request.html.mako rename to website/templates/node_request_institutional_access_request.html.mako index d49b2811ea1..424c15337b3 100644 --- a/website/templates/emails/node_request_institutional_access_request.html.mako +++ b/website/templates/node_request_institutional_access_request.html.mako @@ -3,10 +3,9 @@ <%def name="content()">
      diff --git a/website/templates/notify_base.mako b/website/templates/notify_base.mako new file mode 100644 index 00000000000..565a8d2ead1 --- /dev/null +++ b/website/templates/notify_base.mako @@ -0,0 +1,144 @@ + + + + + + + + + + +<%page args=" + provider_name='OSF', + logo='osf', + logo_url=None, + osf_logo_list=(), + can_change_preferences=True, + can_change_node_preferences=False, + is_reviews_moderator_notification=False, + referrer=False, + is_admin=False, + domain='', + node__id=None, + node_absolute_url=None, + notification_settings_url=None, + osf_contact_email='support@osf.io', + year=2025 +"/> + + + + + + + + + + + + + + +
      + + + + + + +
      +
      + + + ${self.content()} + +
      + + % if can_change_preferences: + + + + + + +
      + % if is_reviews_moderator_notification: +

      + % if not referrer: + You are receiving these emails because you are ${'an administrator' if is_admin else 'a moderator'} on ${provider_name}. + % endif + <% + ns_url = notification_settings_url or (domain + 'settings/notifications/') + %> + To change your moderation notification preferences, + visit your notification settings. +

      + % else: + <% + ns_url = notification_settings_url or (domain + 'settings/notifications/') + node_url = node_absolute_url or ((domain + node__id) if (domain and node__id) else None) + %> +

      + To change how often you receive emails, visit + % if can_change_node_preferences and node_url: + this project's settings for emails about this project or + % endif + your user settings to manage default email settings. +

      + % endif +
      + % endif +
      + + + + + + + +
      + + + +<%def name="content()"> + + + +<%def name="footer()"> + Questions? Email ${osf_contact_email} + diff --git a/website/templates/emails/password_reset.html.mako b/website/templates/password_reset.html.mako similarity index 82% rename from website/templates/emails/password_reset.html.mako rename to website/templates/password_reset.html.mako index 18707ab902f..afe4de25e5d 100644 --- a/website/templates/emails/password_reset.html.mako +++ b/website/templates/password_reset.html.mako @@ -3,14 +3,13 @@ <%def name="content()"> - <%!from website import settings%> - Hello ${user.fullname},
      + Hello ${user_fullname},

      The password for your OSF account has successfully changed.

      If you did not request this action or you believe an unauthorized person has accessed your account, reset your password immediately by visiting:

      - ${settings.DOMAIN + "settings/account"} + ${domain}settings/account
      If you need additional help or have questions, let us know at ${osf_contact_email}.

      diff --git a/website/templates/emails/pending_embargo_admin.html.mako b/website/templates/pending_embargo_admin.html.mako similarity index 72% rename from website/templates/emails/pending_embargo_admin.html.mako rename to website/templates/pending_embargo_admin.html.mako index fb0ab8cf72e..5908e132c14 100644 --- a/website/templates/emails/pending_embargo_admin.html.mako +++ b/website/templates/pending_embargo_admin.html.mako @@ -3,27 +3,26 @@ <%def name="content()"> - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if is_initiator: You have requested final approvals to submit your registration - titled ${reviewable.title}. + titled ${reviewable_title}. % else: - ${initiated_by} has requested final approvals to submit your registration - titled ${reviewable.title}. + ${initiated_by_fullname} has requested final approvals to submit your registration + titled ${reviewable_title}. % endif

      % if is_moderated: If approved by all admin contributors, the registration will be submitted for moderator review. If the moderators approve, the registration will be embargoed until - ${embargo_end_date.date()}, at which time it will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${embargo_end_date}, at which time it will be made public as part of the + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}. % else: If approved by all admin contributors, the registration will be embargoed until - ${embargo_end_date.date()}, at which point it will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${embargo_end_date}, at which point it will be made public as part of the + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}. % endif

      @@ -35,7 +34,7 @@ To cancel this embargoed registration: Click here.

      - % if not reviewable.provider or reviewable.provider._id != 'gfs': + % if reviewable_provider__id != 'gfs': Note: If any admin clicks their cancel link, the submission will be canceled immediately, and the pending registration will be reverted to draft state to revise and resubmit. This operation is irreversible. % else: @@ -61,21 +60,21 @@ % endif

      - % if not reviewable.branched_from_node: + % if not reviewable_branched_from_node:

      - An OSF Project was created from + An OSF Project was created from this registration to support continued collaboration and sharing of your research. This project will remain available even if your registration is rejected.

      You will be automatically subscribed to notification emails for this project. To change your email notification preferences, visit your project or your user settings: - ${settings.DOMAIN}settings/notifications + ${domain}settings/notifications

      % endif

      Sincerely yours,
      - % if not reviewable.provider or reviewable.provider._id != 'gfs': + % if reviewable_provider__id != 'gfs': The OSF Team
      % else: COS and Global Flourishing Study
      diff --git a/website/templates/emails/pending_embargo_non_admin.html.mako b/website/templates/pending_embargo_non_admin.html.mako similarity index 54% rename from website/templates/emails/pending_embargo_non_admin.html.mako rename to website/templates/pending_embargo_non_admin.html.mako index 2d707eb8035..919dcb06d6b 100644 --- a/website/templates/emails/pending_embargo_non_admin.html.mako +++ b/website/templates/pending_embargo_non_admin.html.mako @@ -3,38 +3,37 @@ <%def name="content()"> - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      - ${initiated_by} has requested final approvals to submit your registration - titled ${reviewable.title} + ${initiated_by_fullname} has requested final approvals to submit your registration + titled ${reviewable_title}

      % if is_moderated: If approved by all admin contributors, the registration will be submitted for moderator review. If the moderators approve, the registration will be embargoed until - ${embargo_end_date.date()}, at which time it will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${embargo_end_date}, at which time it will be made public as part of the + ${reviewable_provider_name if reviewable_provider else "OSF Registry"}. % else: If approved by all admin contributors, the registration will be embargoed until - ${embargo_end_date.date()}, at which point it will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${embargo_end_date}, at which point it will be made public as part of the + ${reviewable_provider_name if reviewable_provider else "OSF Registry"}. % endif

      Admins have ${approval_time_span} hours from midnight tonight (EDT) to approve or cancel the registration before the registration is automatically submitted.

      - % if not reviewable.branched_from_node: + % if not reviewable_branched_from_node:

      - An OSF Project was created from + An OSF Project was created from this registration to support continued collaboration and sharing of your research. This project will remain available even if your registration is rejected.

      You will be automatically subscribed to notification emails for this project. To change your email notification preferences, visit your project or your user settings: - ${settings.DOMAIN}settings/notifications + ${domain}settings/notifications

      % endif

      diff --git a/website/templates/emails/pending_embargo_termination_admin.html.mako b/website/templates/pending_embargo_termination_admin.html.mako similarity index 62% rename from website/templates/emails/pending_embargo_termination_admin.html.mako rename to website/templates/pending_embargo_termination_admin.html.mako index cfe2642d521..3b39f6c94cf 100644 --- a/website/templates/emails/pending_embargo_termination_admin.html.mako +++ b/website/templates/pending_embargo_termination_admin.html.mako @@ -3,20 +3,19 @@ <%def name="content()"> - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if is_initiator: You have requested final approvals to end the embargo for your registration - titled ${reviewable.title} + titled ${reviewable_title} % else: - ${initiated_by} has requested final approvals to end the embargo for your registration - titled ${reviewable.title} + ${initiated_by_fullname} has requested final approvals to end the embargo for your registration + titled ${reviewable_title} % endif

      - If all admin contributors appove, the registration will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + If all admin contributors approve, the registration will be made public as part of the + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}.

      You have ${approval_time_span} hours from midnight tonight (EDT) to approve or cancel this diff --git a/website/templates/emails/pending_embargo_termination_non_admin.html.mako b/website/templates/pending_embargo_termination_non_admin.html.mako similarity index 60% rename from website/templates/emails/pending_embargo_termination_non_admin.html.mako rename to website/templates/pending_embargo_termination_non_admin.html.mako index f345adaae6a..b203207dae1 100644 --- a/website/templates/emails/pending_embargo_termination_non_admin.html.mako +++ b/website/templates/pending_embargo_termination_non_admin.html.mako @@ -4,14 +4,14 @@ <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      - ${initiated_by} has requested final approvals to end the embargo for your registration - titled ${reviewable.title} + ${initiated_by_fullname} has requested final approvals to end the embargo for your registration + titled ${reviewable_title}

      If all admins contributors appove, the registration will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}.

      Admins have ${approval_time_span} hours from midnight tonight (EDT) to approve or cancel this diff --git a/website/templates/emails/pending_invite.html.mako b/website/templates/pending_invite.html.mako similarity index 77% rename from website/templates/emails/pending_invite.html.mako rename to website/templates/pending_invite.html.mako index 7d4e72017e5..2f08c3202bd 100644 --- a/website/templates/emails/pending_invite.html.mako +++ b/website/templates/pending_invite.html.mako @@ -3,11 +3,11 @@ <%def name="content()"> - Hello ${fullname},
      + Hello ${user_fullname},

      - We received your request to claim an OSF account and become a contributor for "${node.title}".
      + We received your request to claim an OSF account and become a contributor for "${node_title}".

      - To confirm your identity, ${referrer.fullname} has been sent an email to forward to you with your confirmation link.
      + To confirm your identity, ${referrer_fullname} has been sent an email to forward to you with your confirmation link.

      This link will allow you to complete your registration.

      diff --git a/website/templates/emails/pending_registered.html.mako b/website/templates/pending_registered.html.mako similarity index 68% rename from website/templates/emails/pending_registered.html.mako rename to website/templates/pending_registered.html.mako index 36015a17d1a..79ea7abfed8 100644 --- a/website/templates/emails/pending_registered.html.mako +++ b/website/templates/pending_registered.html.mako @@ -3,13 +3,13 @@ <%def name="content()"> - Hello ${fullname},
      + Hello ${user_fullname},

      - We received your request to become a contributor for "${node.title}".
      + We received your request to become a contributor for "${node_title}".

      - To confirm your identity, ${referrer.fullname} has been sent an email to forward to you with your confirmation link.
      + To confirm your identity, ${referrer_fullname} has been sent an email to forward to you with your confirmation link.

      - This link will allow you to contribute to "${node.title}".
      + This link will allow you to contribute to "${node_title}".

      Thank you for your patience.

      diff --git a/website/templates/emails/pending_registration_admin.html.mako b/website/templates/pending_registration_admin.html.mako similarity index 76% rename from website/templates/emails/pending_registration_admin.html.mako rename to website/templates/pending_registration_admin.html.mako index bbc1e7821f9..ebf11fb1cd8 100644 --- a/website/templates/emails/pending_registration_admin.html.mako +++ b/website/templates/pending_registration_admin.html.mako @@ -3,25 +3,24 @@ <%def name="content()"> - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if is_initiator: You have requested final approvals to submit your registration - titled ${reviewable.title}. + titled ${reviewable_title}. % else: - ${initiated_by} has requested final approvals to submit your registration - titled ${reviewable.title}. + ${initiated_by_fullname} has requested final approvals to submit your registration + titled ${reviewable_title}. % endif

      % if is_moderated: If approved by all admin contributors, the registration will be submitted for moderator review. If the moderators approve, the registration will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}. % else: If approved by all admin contributors, the registration will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}. % endif

      @@ -33,7 +32,7 @@ To cancel this registration: Click here.

      - % if not reviewable.provider or reviewable.provider._id != 'gfs': + % if not reviewable_provider__id != 'gfs': Note: If any admin clicks their cancel link, the submission will be canceled immediately, and the pending registration will be reverted to draft state to revise and resubmit. This operation is irreversible. % else: @@ -59,21 +58,21 @@ % endif

      - % if not reviewable.branched_from_node: + % if not reviewable_branched_from_node:

      - An OSF Project was created from + An OSF Project was created from this registration to support continued collaboration and sharing of your research. This project will remain available even if your registration is rejected.

      You will be automatically subscribed to notification emails for this project. To change your email notification preferences, visit your project or your user settings: - ${settings.DOMAIN}settings/notifications + ${domain}settings/notifications

      % endif

      Sincerely yours,
      - % if not reviewable.provider or reviewable.provider._id != 'gfs': + % if reviewable_provider__id != 'gfs': The OSF Team
      % else: COS and Global Flourishing Study
      diff --git a/website/templates/emails/pending_registration_non_admin.html.mako b/website/templates/pending_registration_non_admin.html.mako similarity index 61% rename from website/templates/emails/pending_registration_non_admin.html.mako rename to website/templates/pending_registration_non_admin.html.mako index 30a45aa7b3c..4dc042cb3f8 100644 --- a/website/templates/emails/pending_registration_non_admin.html.mako +++ b/website/templates/pending_registration_non_admin.html.mako @@ -4,34 +4,34 @@ <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      - ${initiated_by} has requested final approvals to submit your registration - titled ${reviewable.title}. + ${initiated_by_fullname} has requested final approvals to submit your registration + titled ${reviewable_title}.

      % if is_moderated: If approved by all admin contributors, the registration will be submitted for moderator review. If the moderators approve, the registration will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}. % else: If approved by all admin contributors, the registration will be made public as part of the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}. + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}. % endif

      Admins have ${approval_time_span} hours from midnight tonight (EDT) to approve or cancel the registration before the registration is automatically submitted.

      - % if not reviewable.branched_from_node: + % if not reviewable_branched_from_node:

      - An OSF Project was created from + An OSF Project was created from this registration to support continued collaboration and sharing of your research. This project will remain available even if your registration is rejected.

      You will be automatically subscribed to notification emails for this project. To change your email notification preferences, visit your project or your user settings: - ${settings.DOMAIN}settings/notifications + ${domain}settings/notifications

      % endif

      diff --git a/website/templates/emails/pending_retraction_admin.html.mako b/website/templates/pending_retraction_admin.html.mako similarity index 71% rename from website/templates/emails/pending_retraction_admin.html.mako rename to website/templates/pending_retraction_admin.html.mako index 38bb71d1a77..6374e06ef3d 100644 --- a/website/templates/emails/pending_retraction_admin.html.mako +++ b/website/templates/pending_retraction_admin.html.mako @@ -3,21 +3,20 @@ <%def name="content()"> - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if is_initiator: You have requested final approvals to withdraw your registration - titled ${reviewable.title} + titled ${reviewable_title} % else: - ${initiated_by} has requested final approvals to withdraw your registration - titled ${reviewable.title} + ${initiated_by_fullname} has requested final approvals to withdraw your registration + titled ${reviewable_title} % endif

      - % if reviewable.withdrawal_justification: + % if reviewable_withdrawal_justification:

      The registration is being withdrawn for the following reason: -

      ${reviewable.withdrawal_justification}
      +
      ${reviewable_withdrawal_justification}

      % endif

      @@ -28,15 +27,15 @@ If approved by all admin contributors, the registration will be marked as withdrawn. % endif Its content will be removed from the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}, + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}, but basic metadata will be left behind. The title of the withdrawn registration and its list of contributors will remain. - % if reviewable.withdrawal_justification: + % if reviewable_withdrawal_justification: The provided justification or explanation of the withdrawal will also be visible. % endif

      - % if not reviewable.branched_from_node: + % if not reviewable_branched_from_node:

      - Even if the registration is withdrawn, the OSF Project + Even if the registration is withdrawn, the OSF Project created for this registration will remain available.

      % endif diff --git a/website/templates/emails/pending_retraction_non_admin.html.mako b/website/templates/pending_retraction_non_admin.html.mako similarity index 67% rename from website/templates/emails/pending_retraction_non_admin.html.mako rename to website/templates/pending_retraction_non_admin.html.mako index 606af2481ea..d3b885a7cbe 100644 --- a/website/templates/emails/pending_retraction_non_admin.html.mako +++ b/website/templates/pending_retraction_non_admin.html.mako @@ -4,15 +4,15 @@ <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      - ${initiated_by} has requested final approval to withdraw your registration - titled ${reviewable.title} + ${initiated_by_fullname} has requested final approval to withdraw your registration + titled ${reviewable_title}

      - % if reviewable.withdrawal_justification: + % if reviewable_withdrawal_justification:

      The registration is being withdrawn for the following reason: -

      ${reviewable.withdrawal_justification}
      +
      ${reviewable_withdrawal_justification}

      % endif

      @@ -23,15 +23,15 @@ If approved by all admin contributors, the registration will be marked as withdrawn. % endif Its content will be removed from the - ${reviewable.provider.name if reviewable.provider else "OSF Registry"}, + ${reviewable_provider_name if reviewable_provider__id else "OSF Registry"}, but basic metadata will be left behind. The title of the withdrawn registration and its list of contributors will remain. - % if reviewable.withdrawal_justification: + % if reviewable_withdrawal_justification: The provided justification or explanation of the withdrawal will also be visible. % endif

      - % if not reviewable.branched_from_node: + % if not reviewable_branched_from_node:

      - Even if the registration is withdrawn, the OSF Project + Even if the registration is withdrawn, the OSF Project created for this registration will remain available.

      % endif diff --git a/website/templates/emails/primary_email_changed.html.mako b/website/templates/primary_email_changed.html.mako similarity index 92% rename from website/templates/emails/primary_email_changed.html.mako rename to website/templates/primary_email_changed.html.mako index 98e9f7fba7a..9f75e2abcbf 100644 --- a/website/templates/emails/primary_email_changed.html.mako +++ b/website/templates/primary_email_changed.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      The primary email address for your OSF account has been changed to ${new_address}.

      diff --git a/website/templates/emails/project_affiliation_changed.html.mako b/website/templates/project_affiliation_changed.html.mako similarity index 87% rename from website/templates/emails/project_affiliation_changed.html.mako rename to website/templates/project_affiliation_changed.html.mako index cb13ecb98f9..78f00ff2317 100644 --- a/website/templates/emails/project_affiliation_changed.html.mako +++ b/website/templates/project_affiliation_changed.html.mako @@ -3,10 +3,10 @@ <%def name="content()"> - Hello ${user.fullname},
      + Hello ${user_fullname},

      An Institutional admin has made changes to the affiliations of your project: - ${node.title}.
      + ${node_title}.

      Want more information? Visit OSF to learn about OSF, or COS for information about its supporting organization, diff --git a/website/templates/emails/registration_bulk_upload_failure_all.html.mako b/website/templates/registration_bulk_upload_failure_all.html.mako similarity index 96% rename from website/templates/emails/registration_bulk_upload_failure_all.html.mako rename to website/templates/registration_bulk_upload_failure_all.html.mako index 6fde650c7bb..c9f51bae971 100644 --- a/website/templates/emails/registration_bulk_upload_failure_all.html.mako +++ b/website/templates/registration_bulk_upload_failure_all.html.mako @@ -7,7 +7,7 @@ - Hello ${fullname},
      + Hello ${user_fullname},

      All ${count} registrations could not be uploaded. Errors are listed below. Review the file and try to upload the registrations again. Contact the Help Desk at ${osf_support_email} if diff --git a/website/templates/emails/registration_bulk_upload_failure_duplicates.html.mako b/website/templates/registration_bulk_upload_failure_duplicates.html.mako similarity index 96% rename from website/templates/emails/registration_bulk_upload_failure_duplicates.html.mako rename to website/templates/registration_bulk_upload_failure_duplicates.html.mako index 1c5431b9f32..037c5f0d7c7 100644 --- a/website/templates/emails/registration_bulk_upload_failure_duplicates.html.mako +++ b/website/templates/registration_bulk_upload_failure_duplicates.html.mako @@ -7,7 +7,7 @@ - Hello ${fullname},
      + Hello ${user_fullname},

      All ${count} registrations could not be uploaded due to duplicate rows found either within the uploaded csv file or in our system. Duplicates are listed below. Review the file and try to upload the registrations again after diff --git a/website/templates/emails/registration_bulk_upload_product_owner.html.mako b/website/templates/registration_bulk_upload_product_owner.html.mako similarity index 100% rename from website/templates/emails/registration_bulk_upload_product_owner.html.mako rename to website/templates/registration_bulk_upload_product_owner.html.mako diff --git a/website/templates/emails/registration_bulk_upload_success_all.html.mako b/website/templates/registration_bulk_upload_success_all.html.mako similarity index 97% rename from website/templates/emails/registration_bulk_upload_success_all.html.mako rename to website/templates/registration_bulk_upload_success_all.html.mako index 06f98fbc963..4f18fa0c1be 100644 --- a/website/templates/emails/registration_bulk_upload_success_all.html.mako +++ b/website/templates/registration_bulk_upload_success_all.html.mako @@ -7,7 +7,7 @@ - Hello ${fullname},
      + Hello ${user_fullname},

      % if auto_approval: All ${count} of your registrations were successfully uploaded! Click the link below to begin moderating the diff --git a/website/templates/emails/registration_bulk_upload_success_partial.html.mako b/website/templates/registration_bulk_upload_success_partial.html.mako similarity index 97% rename from website/templates/emails/registration_bulk_upload_success_partial.html.mako rename to website/templates/registration_bulk_upload_success_partial.html.mako index 081ff5dd128..324160c394e 100644 --- a/website/templates/emails/registration_bulk_upload_success_partial.html.mako +++ b/website/templates/registration_bulk_upload_success_partial.html.mako @@ -7,7 +7,7 @@ - Hello ${fullname},
      + Hello ${user_fullname},

      % if auto_approval: ${successes} out of ${total} of your registrations were successfully uploaded! Click the link below diff --git a/website/templates/emails/registration_bulk_upload_unexpected_failure.html.mako b/website/templates/registration_bulk_upload_unexpected_failure.html.mako similarity index 95% rename from website/templates/emails/registration_bulk_upload_unexpected_failure.html.mako rename to website/templates/registration_bulk_upload_unexpected_failure.html.mako index 0b1b032d759..dbe70cd1703 100644 --- a/website/templates/emails/registration_bulk_upload_unexpected_failure.html.mako +++ b/website/templates/registration_bulk_upload_unexpected_failure.html.mako @@ -7,7 +7,7 @@ - Hello ${fullname},
      + Hello ${user_fullname},

      Your registrations were not uploaded. Our team was notified of the issue and will follow up after they start looking into the issue. Contact the Help Desk at ${osf_support_email} diff --git a/website/templates/emails/request_deactivation_complete.html.mako b/website/templates/request_deactivation_complete.html.mako similarity index 95% rename from website/templates/emails/request_deactivation_complete.html.mako rename to website/templates/request_deactivation_complete.html.mako index 6ef726186e3..5d48bb6031f 100644 --- a/website/templates/emails/request_deactivation_complete.html.mako +++ b/website/templates/request_deactivation_complete.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Hi ${user.given_name}, + Hi ${user_fullname},

      diff --git a/website/templates/emails/reviews_resubmission_confirmation.html.mako b/website/templates/reviews_resubmission_confirmation.html.mako similarity index 73% rename from website/templates/emails/reviews_resubmission_confirmation.html.mako rename to website/templates/reviews_resubmission_confirmation.html.mako index 23ce18781ba..2b3188de595 100644 --- a/website/templates/emails/reviews_resubmission_confirmation.html.mako +++ b/website/templates/reviews_resubmission_confirmation.html.mako @@ -1,15 +1,15 @@ -<%inherit file="notify_base.mako"/> +<%inherit file="notify_base.mako" /> <%def name="content()">

      - Hello ${referrer.fullname}, + Hello ${user_fullname},

      - The ${document_type} ${reviewable.title} has been successfully - resubmitted to ${reviewable.provider.name}. + The ${document_type} ${reviewable_title} has been successfully + resubmitted to ${reviewable_provider_name}.

      - ${reviewable.provider.name} has chosen to moderate their submissions using a pre-moderation workflow, which + ${reviewable_provider_name} has chosen to moderate their submissions using a pre-moderation workflow, which means your submission is pending until accepted by a moderator. % if not no_future_emails: You will receive a separate notification informing you of any status changes. @@ -20,11 +20,11 @@ for this ${document_type}.

      - If you have been erroneously associated with "${reviewable.title}", then you may visit the ${document_type}'s + If you have been erroneously associated with "${reviewable_title}", then you may visit the ${document_type}'s "Edit" page and remove yourself as a contributor.

      - For more information about ${reviewable.provider.name}, visit ${provider_url} to + For more information about ${reviewable_provider_name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit .

      @@ -33,7 +33,7 @@
      Sincerely,
      - Your ${reviewable.provider.name} and OSF teams + Your ${reviewable_provider_name} and OSF teams

      Center for Open Science
      210 Ridge McIntire Road, Suite 500, Charlottesville, VA 22903

      diff --git a/website/templates/reviews_submission_confirmation.html.mako b/website/templates/reviews_submission_confirmation.html.mako new file mode 100644 index 00000000000..412e6e2eb3c --- /dev/null +++ b/website/templates/reviews_submission_confirmation.html.mako @@ -0,0 +1,91 @@ +## -*- coding: utf-8 -*- +<%inherit file="notify_base.mako" /> +<%def name="content()"> + <% + isOsfSubmission = reviewable_provider_name == 'Open Science Framework' + %> + + + % if document_type == 'registration': +
      + Hello ${user_fullname}, +

      + Your ${document_type} ${reviewable_title} has been successfully submitted to ${reviewable_provider_name}. +

      + ${reviewable_provider_name} has chosen to moderate their submissions using a pre-moderation workflow, which means your submission is pending until accepted by a moderator. +

      + You will receive a separate notification informing you of any status changes. +

      + Learn more about ${reviewable_provider_name} or OSF. +

      + Sincerely, + The ${reviewable_provider_name} and OSF teams. +

      + % else: +
      +

      Hello ${user_fullname},

      + % if is_creator: +

      + Your ${document_type} + ${reviewable_title} + has been successfully submitted to ${reviewable_provider_name}. +

      + % else: +

      + ${referrer_fullname} has added you as a contributor to the + ${document_type} + ${reviewable_title} + on ${reviewable_provider_name}, which is hosted on the OSF. +

      + % endif +

      + % if workflow == 'pre-moderation': + ${reviewable_provider_name} has chosen to moderate their submissions using a pre-moderation workflow, + which means your submission is pending until accepted by a moderator. + % elif workflow == 'post-moderation': + ${reviewable_provider_name} has chosen to moderate their submissions using a + post-moderation workflow, which means your submission is public and discoverable, + while still pending acceptance by a moderator. + % else: + + + + + + +
      + Now that you've shared your ${document_type}, take advantage of more OSF features: +
        +
      • Upload supplemental materials, data, and code to an OSF project associated with your ${document_type}. + Learn how
      • +
      • Preregister your next study. Read more
      • +
      • Or share on social media: Tell your friends through: + + + +
        +
      • +
      +
      + % endif + % if not no_future_emails and not isOsfSubmission: + You will receive a separate notification informing you of any status changes. + % endif +

      + % if not is_creator: +

      + If you have been erroneously associated with "${reviewable_title}," then you may visit the ${document_type} + and remove yourself as a contributor. +

      + % endif +

      Learn more about ${reviewable_provider_name} or OSF.

      +
      +

      + Sincerely,
      + The OSF team +

      +
      + % endif + + + diff --git a/website/templates/emails/reviews_submission_status.html.mako b/website/templates/reviews_submission_status.html.mako similarity index 68% rename from website/templates/emails/reviews_submission_status.html.mako rename to website/templates/reviews_submission_status.html.mako index b0af1a88b45..50d97fb6e3d 100644 --- a/website/templates/emails/reviews_submission_status.html.mako +++ b/website/templates/reviews_submission_status.html.mako @@ -1,15 +1,14 @@ ## -*- coding: utf-8 -*- -<% from website import settings %>
      -

      Hello ${recipient.fullname},

      +

      Hello ${recipient_fullname},

      % if document_type == 'registration': % if is_rejected: - Your submission ${reviewable.title}, submitted to ${reviewable.provider.name}, + Your submission ${reviewable_title}, submitted to ${reviewable_provider_name}, has not been accepted. Your registration was returned as a draft so you can make the appropriate edits for resubmission. - Click here to view your draft. + Click here to view your draft. % else: - Your submission ${reviewable.title}, submitted to ${reviewable.provider.name}, has been accepted by the moderator. + Your submission ${reviewable_title}, submitted to ${reviewable_provider_name}, has been accepted by the moderator. % endif

      % if notify_comment: @@ -18,7 +17,7 @@ % endif % else: % if workflow == 'pre-moderation': - Your submission ${reviewable.title}, submitted to ${reviewable.provider.name} has + Your submission ${reviewable_title}, submitted to ${reviewable_provider_name} has % if is_rejected: not been accepted. Contributors with admin permissions may edit the ${document_type} and resubmit, at which time it will return to a pending state and be reviewed by a moderator. @@ -26,7 +25,7 @@ been accepted by the moderator and is now discoverable to others. % endif % elif workflow == 'post-moderation': - Your submission ${reviewable.title}, submitted to ${reviewable.provider.name} has + Your submission ${reviewable_title}, submitted to ${reviewable_provider_name} has % if is_rejected: not been accepted and will be made private and not discoverable by others. Contributors with admin permissions may edit the ${document_type} and contact @@ -66,18 +65,18 @@ - - twitter + + twitter - - facebook + + facebook - - LinkedIn + + LinkedIn @@ -93,15 +92,15 @@ % endif % if not is_creator:

      - If you have been erroneously associated with "${reviewable.title}," then you + If you have been erroneously associated with "${reviewable_title}," then you may visit the project's "Contributors" page and remove yourself as a contributor.

      % endif % endif -

      Learn more about ${reviewable.provider.name} or OSF.

      +

      Learn more about ${reviewable_provider_name} or OSF.


      Sincerely,
      - The ${reviewable.provider.name} and OSF teams + The ${reviewable_provider_name} and OSF teams

      diff --git a/website/templates/emails/reviews_update_comment.html.mako b/website/templates/reviews_update_comment.html.mako similarity index 75% rename from website/templates/emails/reviews_update_comment.html.mako rename to website/templates/reviews_update_comment.html.mako index 88511a042d6..2cb688a9bdb 100644 --- a/website/templates/emails/reviews_update_comment.html.mako +++ b/website/templates/reviews_update_comment.html.mako @@ -1,8 +1,8 @@ ## -*- coding: utf-8 -*-
      -

      Hello ${recipient.fullname},

      +

      Hello ${recipient_fullname},

      - Your ${document_type} "${reviewable.title}" has an updated comment by the moderator:
      + Your ${document_type} "${reviewable_title}" has an updated comment by the moderator:
      ${comment}

      @@ -12,16 +12,16 @@ email notification preferences, visit your user settings.

      - If you have been erroneously associated with "${reviewable.title}", then you may visit the project's + If you have been erroneously associated with "${reviewable_title}", then you may visit the project's "Contributors" page and remove yourself as a contributor.

      - For more information about ${reviewable.provider.name}, visit ${provider_url} to + For more information about ${reviewable_provider_name}, visit ${provider_url} to learn more. To learn about the Open Science Framework, visit https://osf.io/.

      For questions regarding submission criteria, please email ${provider_contact_email}


      Sincerely,
      - Your ${reviewable.provider.name} and OSF teams + Your ${reviewable_provider_name} and OSF teams
      diff --git a/website/templates/emails/spam_files_detected.html.mako b/website/templates/spam_files_detected.html.mako similarity index 100% rename from website/templates/emails/spam_files_detected.html.mako rename to website/templates/spam_files_detected.html.mako diff --git a/website/templates/emails/spam_user_banned.html.mako b/website/templates/spam_user_banned.html.mako similarity index 91% rename from website/templates/emails/spam_user_banned.html.mako rename to website/templates/spam_user_banned.html.mako index 02dbe0a6387..b195d0a810c 100644 --- a/website/templates/emails/spam_user_banned.html.mako +++ b/website/templates/spam_user_banned.html.mako @@ -3,7 +3,7 @@ <%def name="content()"> - Dear ${user.fullname},
      + Dear ${user_fullname},

      Your account on the Open Science Framework has been flagged as spam and disabled. If this is in error, please email ${osf_support_email} for assistance.

      diff --git a/website/templates/emails/storage_cap_exceeded_announcement.html.mako b/website/templates/storage_cap_exceeded_announcement.html.mako similarity index 88% rename from website/templates/emails/storage_cap_exceeded_announcement.html.mako rename to website/templates/storage_cap_exceeded_announcement.html.mako index fe007e896da..735a9d8e4ef 100644 --- a/website/templates/emails/storage_cap_exceeded_announcement.html.mako +++ b/website/templates/storage_cap_exceeded_announcement.html.mako @@ -3,10 +3,7 @@ <%def name="content()"> - <%! - from website import settings - %> - Hi ${user.given_name or user.fullname},
      + Hi ${user_fullname},

      Thank you for storing your research materials on OSF. We have updated the OSF Storage capacity to 5 GB for private content and 50 GB for public content. None of your current files stored on OSF Storage will be affected, but after November 3, 2020 projects exceeding capacity will no longer accept new file uploads.
      @@ -15,7 +12,7 @@ The following private projects and components have exceeded the 5 GB OSF Storage allotment and require your action:
      @@ -24,7 +21,7 @@ The following public projects and components have exceeded the 50 GB OSF Storage allotment and require your action:
      diff --git a/website/templates/emails/support_request.html.mako b/website/templates/support_request.html.mako similarity index 57% rename from website/templates/emails/support_request.html.mako rename to website/templates/support_request.html.mako index 5d2ad1794f6..e16bca8f346 100644 --- a/website/templates/emails/support_request.html.mako +++ b/website/templates/support_request.html.mako @@ -3,11 +3,11 @@ <%def name="content()"> - ID: ${user._id}
      + ID: ${user__id}

      - Profile: ${user.absolute_url}
      + Profile: ${user_absolute_url}

      - Primary Email: ${user.username}
      + Primary Email: ${user_username}
      diff --git a/website/templates/emails/tou_notif.html.mako b/website/templates/tou_notif.html.mako similarity index 100% rename from website/templates/emails/tou_notif.html.mako rename to website/templates/tou_notif.html.mako diff --git a/website/templates/emails/transactional.html.mako b/website/templates/transactional.html.mako similarity index 88% rename from website/templates/emails/transactional.html.mako rename to website/templates/transactional.html.mako index 5a66b4c9a34..679ce3620e4 100644 --- a/website/templates/emails/transactional.html.mako +++ b/website/templates/transactional.html.mako @@ -1,4 +1,4 @@ -<%inherit file="notify_base.mako"/> +<%inherit file="notify_base.mako" /> <%def name="content()"> @@ -33,6 +33,6 @@ <%def name="footer()">

      You received this email because you are subscribed to email notifications. -
      Update Subscription Preferences +
      Update Subscription Preferences

      diff --git a/website/templates/emails/updates_approved.html.mako b/website/templates/updates_approved.html.mako similarity index 72% rename from website/templates/emails/updates_approved.html.mako rename to website/templates/updates_approved.html.mako index 90d34ed5c2a..fa93855dcb5 100644 --- a/website/templates/emails/updates_approved.html.mako +++ b/website/templates/updates_approved.html.mako @@ -3,8 +3,7 @@ <%def name="content()"> diff --git a/website/templates/emails/updates_initiated.html.mako b/website/templates/updates_initiated.html.mako similarity index 65% rename from website/templates/emails/updates_initiated.html.mako rename to website/templates/updates_initiated.html.mako index 994f7866566..c5a739f5ea3 100644 --- a/website/templates/emails/updates_initiated.html.mako +++ b/website/templates/updates_initiated.html.mako @@ -3,13 +3,12 @@ <%def name="content()"> diff --git a/website/templates/emails/updates_pending_approval.html.mako b/website/templates/updates_pending_approval.html.mako similarity index 64% rename from website/templates/emails/updates_pending_approval.html.mako rename to website/templates/updates_pending_approval.html.mako index 5b595be7c82..9e9ad9714d7 100644 --- a/website/templates/emails/updates_pending_approval.html.mako +++ b/website/templates/updates_pending_approval.html.mako @@ -3,24 +3,23 @@ <%def name="content()"> diff --git a/website/templates/emails/updates_rejected.html.mako b/website/templates/updates_rejected.html.mako similarity index 67% rename from website/templates/emails/updates_rejected.html.mako rename to website/templates/updates_rejected.html.mako index 1559fd93cc2..c621be75768 100644 --- a/website/templates/emails/updates_rejected.html.mako +++ b/website/templates/updates_rejected.html.mako @@ -3,13 +3,12 @@ <%def name="content()"> diff --git a/website/templates/emails/user_message_institutional_access_request.html.mako b/website/templates/user_message_institutional_access_request.html.mako similarity index 64% rename from website/templates/emails/user_message_institutional_access_request.html.mako rename to website/templates/user_message_institutional_access_request.html.mako index 1e314f91e4e..e4c2073e98c 100644 --- a/website/templates/emails/user_message_institutional_access_request.html.mako +++ b/website/templates/user_message_institutional_access_request.html.mako @@ -3,8 +3,7 @@ <%def name="content()"> diff --git a/website/templates/emails/welcome.html.mako b/website/templates/welcome.html.mako similarity index 99% rename from website/templates/emails/welcome.html.mako rename to website/templates/welcome.html.mako index db1d5cf7a08..9b8a40e2065 100644 --- a/website/templates/emails/welcome.html.mako +++ b/website/templates/welcome.html.mako @@ -9,7 +9,7 @@ diff --git a/website/templates/emails/withdrawal_request_granted.html.mako b/website/templates/withdrawal_request_granted.html.mako similarity index 53% rename from website/templates/emails/withdrawal_request_granted.html.mako rename to website/templates/withdrawal_request_granted.html.mako index 78d49617d0b..6d32136d0c6 100644 --- a/website/templates/emails/withdrawal_request_granted.html.mako +++ b/website/templates/withdrawal_request_granted.html.mako @@ -3,61 +3,58 @@ <%def name="content()">
      - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if pending_moderation: The updates for ${resource_type} "${title}" @@ -20,11 +19,11 @@ The OSF Team

      - Want more information? Visit ${settings.DOMAIN} to learn about the OSF, + Want more information? Visit ${domain} to learn about the OSF, or https://cos.io/ for information about its supporting organization, the Center for Open Science.

      - Questions? Email ${settings.OSF_CONTACT_EMAIL} + Questions? Email ${osf_contact_email}}

      - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if is_initiator: You initiated updates for ${resource_type} "${title}". % else: - ${initiator} initiated updates for ${resource_type} "${title}". + ${initiator_fullname} initiated updates for ${resource_type} "${title}". % endif

      % if can_write: @@ -22,10 +21,10 @@ The OSF Team

      - Want more information? Visit ${settings.DOMAIN} to learn about the OSF, + Want more information? Visit ${domain} to learn about the OSF, or https://cos.io/ for information about its supporting organization, the Center for Open Science.

      - Questions? Email ${settings.OSF_CONTACT_EMAIL} + Questions? Email ${osf_contact_email}}

      - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if is_initiator: You submitted updates for ${resource_type} "${title}" for Admin approval. % else: - ${initiator} submitted updates for ${resource_type} "${title}" + ${initiator_fullname} submitted updates for ${resource_type} "${title}" for Admin approval. % endif

      % if is_approver: Click here to review and either approve or reject the submitted updates. Decisions must be made within - ${int(settings.REGISTRATION_UPDATE_APPROVAL_TIME.total_seconds() / 3600)} hours. + ${registration_approval_time} hours. % else: Click here to review the submited updates. - Admins have up to ${int(settings.REGISTRATION_UPDATE_APPROVAL_TIME.total_seconds() / 3600)} hours + Admins have up to ${registration_update_approval_time} hours to make their decision. % endif

      @@ -32,11 +31,11 @@ The OSF Team

      - Want more information? Visit ${settings.DOMAIN} to learn about the OSF, + Want more information? Visit ${domain} to learn about the OSF, or https://cos.io/ for information about its supporting organization, the Center for Open Science.

      - Questions? Email ${settings.OSF_CONTACT_EMAIL} + Questions? Email ${osf_contact_email}}

      - <%!from website import settings%> - Hello ${user.fullname}, + Hello ${user_fullname},

      % if is_initiator: You did not accept the updates for ${resource_type} "${title}". % else: - ${initiator} did not accept the updates for ${resource_type} "${title}". + ${initiator_fullname} did not accept the updates for ${resource_type} "${title}". % endif

      % if can_write: @@ -23,11 +22,11 @@ The OSF Team

      - Want more information? Visit ${settings.DOMAIN} to learn about the OSF, + Want more information? Visit ${domain} to learn about the OSF, or https://cos.io/ for information about its supporting organization, the Center for Open Science.

      - Questions? Email ${settings.OSF_CONTACT_EMAIL} + Questions? Email ${osf_contact_email}}

      - <%!from website import settings%> - Hello ${recipient.fullname}, + Hello ${recipient_fullname},

      This message is coming from an Institutional administrator within your Institution.

      @@ -14,12 +13,12 @@

      % endif

      - Want more information? Visit ${settings.DOMAIN} to learn about OSF, or + Want more information? Visit ${domain} to learn about OSF, or https://cos.io/ for information about its supporting organization, the Center for Open Science.

      - Questions? Email ${settings.OSF_CONTACT_EMAIL} + Questions? Email ${osf_contact_email}

      -Hello ${user.fullname},
      +Hello ${user_fullname},

      Thank you for verifying your account on OSF, a free, open source service maintained by the Center for Open Science. Here are a few things you can do with OSF:
      diff --git a/website/templates/emails/welcome_osf4i.html.mako b/website/templates/welcome_osf4i.html.mako similarity index 99% rename from website/templates/emails/welcome_osf4i.html.mako rename to website/templates/welcome_osf4i.html.mako index ec6273031ab..42df2d00a4d 100644 --- a/website/templates/emails/welcome_osf4i.html.mako +++ b/website/templates/welcome_osf4i.html.mako @@ -9,7 +9,7 @@
      -Hello ${user.fullname},
      +Hello ${user_fullname},

      Thank you for verifying your account on OSF, a free, open source service maintained by the Center for Open Science. Here are a few things you can do with OSF:
      diff --git a/website/templates/emails/welcome_osf4m.html.mako b/website/templates/welcome_osf4m.html.mako similarity index 78% rename from website/templates/emails/welcome_osf4m.html.mako rename to website/templates/welcome_osf4m.html.mako index b5920b1aafb..e46c5ae8aef 100644 --- a/website/templates/emails/welcome_osf4m.html.mako +++ b/website/templates/welcome_osf4m.html.mako @@ -1,11 +1,11 @@ ## -*- coding: utf-8 -*- -<%inherit file="notify_base.mako"/> +<%inherit file="notify_base.mako" /> <%def name="content()">

      - Hello ${fullname}, + Hello ${user_fullname},

      - Thanks for adding your presentation from ${conference} to the conference's OSF Meetings page! Sharing virtually is an easy way to increase the impact of your research. + Thanks for adding your presentation from ${conference} to the conference's OSF Meetings page! Sharing virtually is an easy way to increase the impact of your research.
      %if downloads > 4: Your project files have been downloaded ${downloads} times! @@ -27,5 +27,5 @@ <%def name="footer()">
      - The OSF is provided as a free, open source service from the Center for Open Science. + The OSF is provided as a free, open source service from the Center for Open Science. diff --git a/website/templates/emails/withdrawal_request_declined.html.mako b/website/templates/withdrawal_request_declined.html.mako similarity index 60% rename from website/templates/emails/withdrawal_request_declined.html.mako rename to website/templates/withdrawal_request_declined.html.mako index 79cbf7b197e..42251f28bd5 100644 --- a/website/templates/emails/withdrawal_request_declined.html.mako +++ b/website/templates/withdrawal_request_declined.html.mako @@ -3,25 +3,22 @@ <%def name="content()">
      - <%! - from website import settings - %> % if document_type == 'registration': - Dear ${contributor.fullname}, + Dear ${contributor_fullname},

      - Your request to withdraw your registration "${reviewable.title}" from ${reviewable.provider.name} has been declined by the service moderators. The registration is still publicly available on ${reviewable.provider.name}. + Your request to withdraw your registration "${reviewable_title}" from ${reviewable_provider_name} has been declined by the service moderators. The registration is still publicly available on ${reviewable_provider_name}.

      % if notify_comment: The moderator has provided the following comment:
      ${comment} % endif % else: - Dear ${requester.fullname}, + Dear ${requester_fullname},

      - Your request to withdraw your ${document_type} "${reviewable.title}" from ${reviewable.provider.name} has been declined by the service moderators. Login and visit your ${document_type} to view their feedback. The ${document_type} is still publicly available on ${reviewable.provider.name}. + Your request to withdraw your ${document_type} "${reviewable_title}" from ${reviewable_provider_name} has been declined by the service moderators. Login and visit your ${document_type} to view their feedback. The ${document_type} is still publicly available on ${reviewable_provider_name}. % endif

      Sincerely,
      - The ${reviewable.provider.name} and OSF Teams
      + The ${reviewable_provider_name} and OSF Teams

      - <%! - from website import settings - %> - Dear ${contributor.fullname}, + Dear ${contributor_fullname},

      % if document_type == 'registration': % if force_withdrawal: - A moderator has withdrawn your ${document_type} "${reviewable.title}" from ${reviewable.provider.name}. + A moderator has withdrawn your ${document_type} "${reviewable_title}" from ${reviewable_provider_name}. % else: - Your request to withdraw your ${document_type} "${reviewable.title}" has been approved by ${reviewable.provider.name} moderators. + Your request to withdraw your ${document_type} "${reviewable_title}" has been approved by ${reviewable_provider_name} moderators. % endif - % if notify_comment: + % if comment and notify_comment:

      The moderator has provided the following comment:
      ${comment} % endif

      - The ${document_type} has been removed from ${reviewable.provider.name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, DOI, and reason for withdrawal (if provided). + The ${document_type} has been removed from ${reviewable_provider_name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, DOI, and reason for withdrawal (if provided). % else: % if not ever_public: % if is_requester: - You have withdrawn your ${document_type} "${reviewable.title}" from ${reviewable.provider.name}. + You have withdrawn your ${document_type} "${reviewable_title}" from ${reviewable_provider_name}.
      - The ${document_type} has been removed from ${reviewable.provider.name}. + The ${document_type} has been removed from ${reviewable_provider_name}.
      % else: - ${requester.fullname} has withdrawn your ${document_type} "${reviewable.title}" from ${reviewable.provider.name}. - % if reviewable.withdrawal_justification: - ${requester.fullname} provided the following justification: "${reviewable.withdrawal_justification}" + ${requester_fullname} has withdrawn your ${document_type} "${reviewable_title}" from ${reviewable_provider_name}. + % if reviewable_withdrawal_justification: + ${requester_fullname} provided the following justification: "${reviewable_withdrawal_justification}" % endif
      - The ${document_type} has been removed from ${reviewable.provider.name}. + The ${document_type} has been removed from ${reviewable_provider_name}.
      % endif % else: % if is_requester: - Your request to withdraw your ${document_type} "${reviewable.title}" from ${reviewable.provider.name} has been approved by the service moderators. + Your request to withdraw your ${document_type} "${reviewable_title}" from ${reviewable_provider_name} has been approved by the service moderators.
      - The ${document_type} has been removed from ${reviewable.provider.name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, DOI, and reason for withdrawal (if provided). + The ${document_type} has been removed from ${reviewable_provider_name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, DOI, and reason for withdrawal (if provided).
      % elif force_withdrawal: - A moderator has withdrawn your ${document_type} "${reviewable.title}" from ${reviewable.provider.name}. + A moderator has withdrawn your ${document_type} "${reviewable_title}" from ${reviewable_provider_name}.
      - The ${document_type} has been removed from ${reviewable.provider.name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, and DOI. - % if reviewable.withdrawal_justification: - The moderator has provided the following justification: "${reviewable.withdrawal_justification}". + The ${document_type} has been removed from ${reviewable_provider_name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, and DOI. + % if reviewable_withdrawal_justification: + The moderator has provided the following justification: "${reviewable_withdrawal_justification}".
      % endif
      % else: - ${requester.fullname} has withdrawn your ${document_type} "${reviewable.title}" from ${reviewable.provider.name}. + ${requester_fullname} has withdrawn your ${document_type} "${reviewable_title}" from ${reviewable_provider_name}.
      - The ${document_type} has been removed from ${reviewable.provider.name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, and DOI. - % if reviewable.withdrawal_justification: - ${requester.fullname} provided the following justification: "${reviewable.withdrawal_justification}". + The ${document_type} has been removed from ${reviewable_provider_name}, but its metadata is still available: title of the withdrawn ${document_type}, its contributor list, abstract, tags, and DOI. + % if reviewable_withdrawal_justification: + ${requester_fullname} provided the following justification: "${reviewable_withdrawal_justification}".
      % endif
      @@ -66,6 +63,6 @@ % endif

      Sincerely,
      - The ${reviewable.provider.name} and OSF Teams + The ${reviewable_provider_name} and OSF Teams