diff --git a/admin/base/urls.py b/admin/base/urls.py index 9ff5e03a03e..1eb840bc362 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -38,6 +38,7 @@ re_path(r'^draft_registrations/', include('admin.draft_registrations.urls', namespace='draft_registrations')), re_path(r'^files/', include('admin.files.urls', namespace='files')), re_path(r'^share_reindex/', include('admin.share_reindex.urls', namespace='share_reindex')), + re_path(r'^notifications/', include('admin.notifications.urls', namespace='notifications')), ]), ), ] diff --git a/admin/notifications/forms.py b/admin/notifications/forms.py new file mode 100644 index 00000000000..946754415bb --- /dev/null +++ b/admin/notifications/forms.py @@ -0,0 +1,8 @@ +from django import forms +from osf.models import NotificationType + + +class NotificationTypeForm(forms.ModelForm): + class Meta: + model = NotificationType + fields = '__all__' diff --git a/admin/notifications/urls.py b/admin/notifications/urls.py new file mode 100644 index 00000000000..236059a577e --- /dev/null +++ b/admin/notifications/urls.py @@ -0,0 +1,14 @@ +from django.urls import re_path +from . import views + +app_name = 'admin' + +urlpatterns = [ + re_path(r'$', views.NotificationsList.as_view(), name='list'), + re_path(r'types/$', views.NotificationTypeList.as_view(), name='types_list'), + re_path(r'type_display/(?P\d+)/$', views.NotificationTypeDisplay.as_view(), name='type_display'), + re_path(r'type_detail/(?P\d+)/$', views.NotificationTypeDetail.as_view(), name='type_detail'), + re_path(r'types_preview/(?P\d+)/$', views.NotificationTypePreview.as_view(), name='types_preview'), + re_path(r'subscriptions/$', views.NotificationSubscriptionsList.as_view(), name='subscriptions_list'), + re_path(r'email_tasks/$', views.EmailTasksList.as_view(), name='email_tasks_list'), +] diff --git a/admin/notifications/views.py b/admin/notifications/views.py index 6719ac90a8a..e1c55c05f47 100644 --- a/admin/notifications/views.py +++ b/admin/notifications/views.py @@ -1,4 +1,329 @@ -from osf.models.notification_subscription import NotificationSubscription +from django.urls import reverse_lazy +from django.db.models import Q +from osf.models import NotificationSubscription, NotificationType, Notification, EmailTask +from django.views.generic import ListView, DetailView, UpdateView +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.forms.models import model_to_dict +from .forms import NotificationTypeForm +from osf.email import _render_email_html +import json +from collections import defaultdict +from mako.lexer import Lexer +from mako.parsetree import ControlLine +import re def delete_selected_notifications(selected_ids): NotificationSubscription.objects.filter(id__in=selected_ids).delete() + +TEMPLATE_IDENTIFIER_BLACKLIST = { + 'if', 'else', 'and', 'or', 'not', 'in', + 'True', 'False', 'len', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', +} + +def resolve_identifiers(identifier_structure): + structure = defaultdict(dict) + if hasattr(identifier_structure, 'nodes') and identifier_structure.nodes: + for node in identifier_structure.nodes: + if isinstance(node, ControlLine) and node.keyword == 'for': + match = re.match(r'for (\w+) in (.+):', node.text) + if match: + iterator, source = match.groups() + structure[node.text] = { + 'type': 'loop', + 'iterator': iterator, + 'source': source, + 'children': resolve_identifiers(node) + } + elif hasattr(node, 'text'): + field_match = re.match(r"(\w+)\['(.+)'\]", node.text) + if field_match: + source, field = field_match.groups() + structure[node.text] = { + 'type': 'field', + 'source': source, + 'field': field + } + return structure + +def generate_mock_json(structure, list_name=None): + item = {} + result = {} + for key, value in structure.items(): + # simple field + if isinstance(value, dict) and value.get('type') == 'field': + field_name = value['field'] + item[field_name] = f"mock_{field_name}" + + # nested loop + elif isinstance(value, dict) and value.get('type') == 'loop': + nested_source = value['source'] + nested_match = re.match(r"\w+\['(.+)'\]", nested_source) + if nested_match: + nested_field = nested_match.group(1) + item[nested_field] = [1, 2, 3, 4] + + # top-level loop wrapper + elif key.startswith('for '): + match = re.match(r'for (\w+) in (.+):', key) + if match: + _, source = match.groups() + # Extract final field name + field_match = re.search(r"(\w+)\['(.+?)'\]$", source) + if field_match: + field_name = field_match.group(1) + list_name = field_match.group(2) + return {field_name: generate_mock_json(value, list_name)} + else: + list_name = source + return generate_mock_json(value, list_name) + if list_name: + result[list_name] = [item, item, item] + + return result + + +def build_safe_context(template: str) -> dict: + templatenode = Lexer(text=template).parse() + identifiers_location = [] + for node in templatenode.get_children(): + if hasattr(node, 'nodes'): + identifiers_location.extend(node.nodes) + + if not identifiers_location: + identifiers_location = templatenode.get_children() + identifier_structure = defaultdict() + for control_structure in identifiers_location: + if isinstance(control_structure, ControlLine): + identifier_structure[control_structure.text] = resolve_identifiers(control_structure) + + identifiers = [x.undeclared_identifiers() for x in identifiers_location if hasattr(x, 'undeclared_identifiers')] + flatten_identifiers = set() + for indentifier_set in identifiers: + flatten_identifiers.update(indentifier_set) + mock_json = generate_mock_json(identifier_structure) + context = {identifier: f'mock_{identifier}' for identifier in flatten_identifiers if identifier not in TEMPLATE_IDENTIFIER_BLACKLIST} + context.update(mock_json) + return context + +class NotificationsList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/notifications_list.html' + ordering = 'id' + permission_required = 'osf.view_notification' + raise_exception = True + model = Notification + + def get_queryset(self): + qs = Notification.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(subscription__notification_type__name__icontains=q) | + Q(subscription__user__username__icontains=q) | + Q(subscription__message_frequency__icontains=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + + context['notifications'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class NotificationSubscriptionsList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/notification_subscriptions_list.html' + ordering = 'id' + permission_required = 'osf.view_notificationsubscription' + raise_exception = True + model = NotificationSubscription + + def get_queryset(self): + qs = NotificationSubscription.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(notification_type__name__icontains=q) | + Q(user__username__icontains=q) | + Q(message_frequency__icontains=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + context['subscriptions'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class EmailTasksList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/email_tasks_list.html' + ordering = 'task_id' + permission_required = 'osf.view_emailtask' + raise_exception = True + model = EmailTask + + def get_queryset(self): + qs = EmailTask.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(task_id=q) | + Q(user__username__icontains=q) | + Q(status=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + context['email_tasks'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class NotificationTypeList(PermissionRequiredMixin, ListView): + paginate_by = 25 + template_name = 'notifications/notification_types_list.html' + ordering = 'name' + permission_required = 'osf.view_notificationtype' + raise_exception = True + model = NotificationType + + def get_queryset(self): + qs = NotificationType.objects.all().order_by(self.ordering) + q = self.request.GET.get('q') + if q: + qs = qs.filter( + Q(name__icontains=q) | + Q(subject__icontains=q) | + Q(notification_interval_choices__icontains=q) + ) + return qs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + q = self.request.GET.get('q', '') + context['q'] = q + # append search param to pagination links + if q: + context['extra_query_params'] = f"&q={q}" + else: + context['extra_query_params'] = '' + + context['notification_types'] = context['object_list'] + context['page'] = context['page_obj'] + return context + +class NotificationTypeDisplay(PermissionRequiredMixin, DetailView): + model = NotificationType + template_name = 'notifications/notification_type_detail.html' + permission_required = 'osf.view_notificationtype' + raise_exception = True + + def get_object(self, queryset=None): + return NotificationType.objects.get(id=self.kwargs.get('pk')) + + def get_context_data(self, *args, **kwargs): + notification_type = self.get_object() + notification_type_dict = model_to_dict(notification_type) + fields = notification_type_dict.copy() + kwargs.setdefault('page_number', self.request.GET.get('page', '1')) + notification_type_dict['is_digest_type'] = notification_type.is_digest_type + kwargs['notification_type'] = notification_type_dict + kwargs['template'] = notification_type_dict.pop('template', None) + kwargs['change_form'] = NotificationTypeForm(initial=fields) + + return kwargs + +class NotificationTypePreview(PermissionRequiredMixin, DetailView): + model = NotificationType + template_name = 'notifications/notification_type_preview.html' + permission_required = 'osf.view_notificationtype' + raise_exception = True + + def get_object(self, queryset=None): + return NotificationType.objects.get(id=self.kwargs.get('pk')) + + def get_context_data(self, *args, **kwargs): + notification_type = self.get_object() + raw_context = self.request.GET.get('context') + if raw_context: + try: + if notification_type.is_digest_type: + safe_context = {'notifications': [json.loads(raw_context)]} + else: + safe_context = json.loads(raw_context) + + return_context = json.loads(raw_context) + except json.JSONDecodeError as e: + kwargs['rendered_template'] = f"Error parsing JSON: {str(e)}" + kwargs['context'] = raw_context + return kwargs + else: + if notification_type.is_digest_type: + inner_context = build_safe_context(notification_type.template) + inner_template = _render_email_html(notification_type, ctx=inner_context, return_original_error=True) + safe_context = {'notifications': [inner_template]} + return_context = inner_context + else: + safe_context = build_safe_context(notification_type.template) + return_context = safe_context + + if notification_type.is_digest_type: + # Use user_digest template as a wrapper for digest notification preview. + template_obj = NotificationType.objects.get(name='user_digest') + else: + template_obj = notification_type + try: + kwargs['rendered_template'] = _render_email_html(template_obj, ctx=safe_context, return_original_error=True) + except Exception as e: + kwargs['rendered_template'] = f"Error rendering template: {str(e)}" + + kwargs['context'] = json.dumps(return_context, indent=4) + + return kwargs + +class NotificationTypeDetail(PermissionRequiredMixin, DetailView): + model = NotificationType + template_name = 'notifications/notification_type_detail.html' + permission_required = 'osf.view_notificationtype' + raise_exception = True + + def get(self, request, *args, **kwargs): + view = NotificationTypeDetail.as_view() + return view(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + view = NotificationTypeChangeForm.as_view() + return view(request, *args, **kwargs) + +class NotificationTypeChangeForm(PermissionRequiredMixin, UpdateView): + template_name = 'institutions/detail.html' + permission_required = 'osf.change_notificationtype' + raise_exception = True + model = NotificationType + form_class = NotificationTypeForm + + def get_success_url(self, *args, **kwargs): + return reverse_lazy('notifications:type_display', kwargs={'pk': self.kwargs.get('pk')}) diff --git a/admin/templates/base.html b/admin/templates/base.html index 31a89b74037..5f645ffe267 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -289,6 +289,28 @@ {% endif %} {% endif %} + {% if perms.osf.view_notification or perms.osf.view_notificationtype or perms.osf.view_notificationsubscription %} +
  • + Notifications +
  • +
    + +
    + {% endif %} {% if perms.osf.view_metrics %}
  • Metrics
  • {% endif %} diff --git a/admin/templates/notifications/email_tasks_list.html b/admin/templates/notifications/email_tasks_list.html new file mode 100644 index 00000000000..cf4345f8907 --- /dev/null +++ b/admin/templates/notifications/email_tasks_list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Email Tasks +{% endblock title %} +{% block content %} +

    List of Email Tasks

    + + {% include "util/pagination.html" with items=page status=status %} +
    + + +
    + + + + + + + + + + {% for email_task in email_tasks %} + + + + + + {% endfor %} + +
    Task IDUserStatus
    {{ email_task.task_id }}{{ email_task.user }}{{ email_task.status }}
    + +{% endblock content %} diff --git a/admin/templates/notifications/notification_subscriptions_list.html b/admin/templates/notifications/notification_subscriptions_list.html new file mode 100644 index 00000000000..7ed0febd384 --- /dev/null +++ b/admin/templates/notifications/notification_subscriptions_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Notification Subscriptions +{% endblock title %} +{% block content %} +

    List of Notification Subscriptions

    + + {% include "util/pagination.html" with items=page status=status %} +
    + + +
    + + + + + + + + + + + {% for subscription in subscriptions %} + + + + + + + + {% endfor %} + +
    Notification Type NameUserMessage FrequencySubscribed Object
    {{ subscription.notification_type.name }}{{ subscription.user }}{{ subscription.message_frequency }}{{ subscription.subscribed_object }}
    + +{% endblock content %} diff --git a/admin/templates/notifications/notification_type_detail.html b/admin/templates/notifications/notification_type_detail.html new file mode 100644 index 00000000000..1a571099762 --- /dev/null +++ b/admin/templates/notifications/notification_type_detail.html @@ -0,0 +1,109 @@ +{% extends "base.html" %} +{% load static %} +{% load render_bundle from webpack_loader %} +{% block title %} + Notification Type +{% endblock title %} +{% block content %} +
    +
    +
    + +
    +
    +
    +
    +

    {{ notification_type.name }}

    +
    +
    +
    +
    + +
    +
    +
    +
    + + {% for field, value in notification_type.items %} + + + + + {% endfor %} + + + + + +
    {{ field }}{{ value | safe }}
    template
    {{ template }}
    +
    + +
    +
    +
    +
    +
    + +{% endblock content %} + +{% block bottom_js %} + +{% endblock %} diff --git a/admin/templates/notifications/notification_type_preview.html b/admin/templates/notifications/notification_type_preview.html new file mode 100644 index 00000000000..e9aefbe3284 --- /dev/null +++ b/admin/templates/notifications/notification_type_preview.html @@ -0,0 +1,30 @@ +

    Notification Template Preview

    +

    Rendered Template

    + + + +
    + {{ rendered_template|safe }} +
    +

    Mock Data

    +
    + Edit the mock data and click "Render Preview" to see how changes affect the rendered template.
    + Note: The mock data should be a JSON object matching the expected context for the template. Please ensure that the JSON is properly formatted if error occurs during rendering. +
    +
    + +
    diff --git a/admin/templates/notifications/notification_types_list.html b/admin/templates/notifications/notification_types_list.html new file mode 100644 index 00000000000..655e8ec1ab2 --- /dev/null +++ b/admin/templates/notifications/notification_types_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Notification Types +{% endblock title %} +{% block content %} +

    List of Notification Types

    + + {% include "util/pagination.html" with items=page status=status %} +
    + + +
    + + + + + + + + + + + {% for notification_type in notification_types %} + + + + + + + + {% endfor %} + +
    NameSubjectInterval ChoicesIs Digest
    {{ notification_type.name }}{{ notification_type.subject }}{{ notification_type.notification_interval_choices }}{{ notification_type.is_digest_type }}
    + +{% endblock content %} diff --git a/admin/templates/notifications/notifications_list.html b/admin/templates/notifications/notifications_list.html new file mode 100644 index 00000000000..76e747ca5e1 --- /dev/null +++ b/admin/templates/notifications/notifications_list.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% load render_bundle from webpack_loader %} +{% load static %} +{% block title %} + List of Notifications +{% endblock title %} +{% block content %} +

    List of Notifications

    + + {% include "util/pagination.html" with items=page status=status %} +
    + + +
    + + + + + + + + + + + {% for notification in notifications %} + + + + + + + + {% endfor %} + +
    Notification Type NameUserSentIs fake sent
    {{ notification.subscription.notification_type.name }}{{ notification.subscription.user }}{{ notification.sent }}{{ notification.fake_sent }}
    + +{% endblock content %} diff --git a/osf/email/__init__.py b/osf/email/__init__.py index bb22bbcd637..1cf39af809b 100644 --- a/osf/email/__init__.py +++ b/osf/email/__init__.py @@ -149,7 +149,7 @@ def _read_lookup_uri(uri: str) -> str: 'domain': settings.DOMAIN, } -def _render_email_html(notification_type, ctx: dict) -> str: +def _render_email_html(notification_type, ctx: dict, return_original_error: bool = False) -> str: template_text = notification_type.template if not template_text: return '' @@ -172,7 +172,9 @@ def _render_email_html(notification_type, ctx: dict) -> str: strict_undefined=True, ).render(**(ctx or {})) - except Exception: + except Exception as e: + if return_original_error: + raise e 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, diff --git a/osf/models/notification_type.py b/osf/models/notification_type.py index e0afdab7aea..f8162a08bce 100644 --- a/osf/models/notification_type.py +++ b/osf/models/notification_type.py @@ -160,6 +160,7 @@ def is_digest_type(self): NotificationTypeEnum.ADDON_FILE_COPIED.value, NotificationTypeEnum.ADDON_FILE_MOVED.value, NotificationTypeEnum.ADDON_FILE_RENAMED.value, + NotificationTypeEnum.ADDON_FILE_REMOVED.value, NotificationTypeEnum.FILE_ADDED.value, NotificationTypeEnum.FILE_REMOVED.value, NotificationTypeEnum.FILE_UPDATED.value,