Skip to content

Commit b985424

Browse files
[ENG-10775] Move notification management from admin/admin to badmin and improve template preview (#11715)
* Refactor notifications admin interface: add views, templates, and URLs for notifications management * Refactor notification rendering: enhance error handling and add mock data support in preview * Add JSON parsing error
1 parent e3ab66a commit b985424

13 files changed

Lines changed: 660 additions & 3 deletions

admin/base/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
re_path(r'^draft_registrations/', include('admin.draft_registrations.urls', namespace='draft_registrations')),
3939
re_path(r'^files/', include('admin.files.urls', namespace='files')),
4040
re_path(r'^share_reindex/', include('admin.share_reindex.urls', namespace='share_reindex')),
41+
re_path(r'^notifications/', include('admin.notifications.urls', namespace='notifications')),
4142
]),
4243
),
4344
]

admin/notifications/forms.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django import forms
2+
from osf.models import NotificationType
3+
4+
5+
class NotificationTypeForm(forms.ModelForm):
6+
class Meta:
7+
model = NotificationType
8+
fields = '__all__'

admin/notifications/urls.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from django.urls import re_path
2+
from . import views
3+
4+
app_name = 'admin'
5+
6+
urlpatterns = [
7+
re_path(r'$', views.NotificationsList.as_view(), name='list'),
8+
re_path(r'types/$', views.NotificationTypeList.as_view(), name='types_list'),
9+
re_path(r'type_display/(?P<pk>\d+)/$', views.NotificationTypeDisplay.as_view(), name='type_display'),
10+
re_path(r'type_detail/(?P<pk>\d+)/$', views.NotificationTypeDetail.as_view(), name='type_detail'),
11+
re_path(r'types_preview/(?P<pk>\d+)/$', views.NotificationTypePreview.as_view(), name='types_preview'),
12+
re_path(r'subscriptions/$', views.NotificationSubscriptionsList.as_view(), name='subscriptions_list'),
13+
re_path(r'email_tasks/$', views.EmailTasksList.as_view(), name='email_tasks_list'),
14+
]

admin/notifications/views.py

Lines changed: 326 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,329 @@
1-
from osf.models.notification_subscription import NotificationSubscription
1+
from django.urls import reverse_lazy
2+
from django.db.models import Q
3+
from osf.models import NotificationSubscription, NotificationType, Notification, EmailTask
4+
from django.views.generic import ListView, DetailView, UpdateView
5+
from django.contrib.auth.mixins import PermissionRequiredMixin
6+
from django.forms.models import model_to_dict
7+
from .forms import NotificationTypeForm
8+
from osf.email import _render_email_html
9+
import json
10+
from collections import defaultdict
11+
from mako.lexer import Lexer
12+
from mako.parsetree import ControlLine
13+
import re
214

315
def delete_selected_notifications(selected_ids):
416
NotificationSubscription.objects.filter(id__in=selected_ids).delete()
17+
18+
TEMPLATE_IDENTIFIER_BLACKLIST = {
19+
'if', 'else', 'and', 'or', 'not', 'in',
20+
'True', 'False', 'len', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple',
21+
}
22+
23+
def resolve_identifiers(identifier_structure):
24+
structure = defaultdict(dict)
25+
if hasattr(identifier_structure, 'nodes') and identifier_structure.nodes:
26+
for node in identifier_structure.nodes:
27+
if isinstance(node, ControlLine) and node.keyword == 'for':
28+
match = re.match(r'for (\w+) in (.+):', node.text)
29+
if match:
30+
iterator, source = match.groups()
31+
structure[node.text] = {
32+
'type': 'loop',
33+
'iterator': iterator,
34+
'source': source,
35+
'children': resolve_identifiers(node)
36+
}
37+
elif hasattr(node, 'text'):
38+
field_match = re.match(r"(\w+)\['(.+)'\]", node.text)
39+
if field_match:
40+
source, field = field_match.groups()
41+
structure[node.text] = {
42+
'type': 'field',
43+
'source': source,
44+
'field': field
45+
}
46+
return structure
47+
48+
def generate_mock_json(structure, list_name=None):
49+
item = {}
50+
result = {}
51+
for key, value in structure.items():
52+
# simple field
53+
if isinstance(value, dict) and value.get('type') == 'field':
54+
field_name = value['field']
55+
item[field_name] = f"mock_{field_name}"
56+
57+
# nested loop
58+
elif isinstance(value, dict) and value.get('type') == 'loop':
59+
nested_source = value['source']
60+
nested_match = re.match(r"\w+\['(.+)'\]", nested_source)
61+
if nested_match:
62+
nested_field = nested_match.group(1)
63+
item[nested_field] = [1, 2, 3, 4]
64+
65+
# top-level loop wrapper
66+
elif key.startswith('for '):
67+
match = re.match(r'for (\w+) in (.+):', key)
68+
if match:
69+
_, source = match.groups()
70+
# Extract final field name
71+
field_match = re.search(r"(\w+)\['(.+?)'\]$", source)
72+
if field_match:
73+
field_name = field_match.group(1)
74+
list_name = field_match.group(2)
75+
return {field_name: generate_mock_json(value, list_name)}
76+
else:
77+
list_name = source
78+
return generate_mock_json(value, list_name)
79+
if list_name:
80+
result[list_name] = [item, item, item]
81+
82+
return result
83+
84+
85+
def build_safe_context(template: str) -> dict:
86+
templatenode = Lexer(text=template).parse()
87+
identifiers_location = []
88+
for node in templatenode.get_children():
89+
if hasattr(node, 'nodes'):
90+
identifiers_location.extend(node.nodes)
91+
92+
if not identifiers_location:
93+
identifiers_location = templatenode.get_children()
94+
identifier_structure = defaultdict()
95+
for control_structure in identifiers_location:
96+
if isinstance(control_structure, ControlLine):
97+
identifier_structure[control_structure.text] = resolve_identifiers(control_structure)
98+
99+
identifiers = [x.undeclared_identifiers() for x in identifiers_location if hasattr(x, 'undeclared_identifiers')]
100+
flatten_identifiers = set()
101+
for indentifier_set in identifiers:
102+
flatten_identifiers.update(indentifier_set)
103+
mock_json = generate_mock_json(identifier_structure)
104+
context = {identifier: f'mock_{identifier}' for identifier in flatten_identifiers if identifier not in TEMPLATE_IDENTIFIER_BLACKLIST}
105+
context.update(mock_json)
106+
return context
107+
108+
class NotificationsList(PermissionRequiredMixin, ListView):
109+
paginate_by = 25
110+
template_name = 'notifications/notifications_list.html'
111+
ordering = 'id'
112+
permission_required = 'osf.view_notification'
113+
raise_exception = True
114+
model = Notification
115+
116+
def get_queryset(self):
117+
qs = Notification.objects.all().order_by(self.ordering)
118+
q = self.request.GET.get('q')
119+
if q:
120+
qs = qs.filter(
121+
Q(subscription__notification_type__name__icontains=q) |
122+
Q(subscription__user__username__icontains=q) |
123+
Q(subscription__message_frequency__icontains=q)
124+
)
125+
return qs
126+
127+
def get_context_data(self, **kwargs):
128+
context = super().get_context_data(**kwargs)
129+
q = self.request.GET.get('q', '')
130+
context['q'] = q
131+
# append search param to pagination links
132+
if q:
133+
context['extra_query_params'] = f"&q={q}"
134+
else:
135+
context['extra_query_params'] = ''
136+
137+
context['notifications'] = context['object_list']
138+
context['page'] = context['page_obj']
139+
return context
140+
141+
class NotificationSubscriptionsList(PermissionRequiredMixin, ListView):
142+
paginate_by = 25
143+
template_name = 'notifications/notification_subscriptions_list.html'
144+
ordering = 'id'
145+
permission_required = 'osf.view_notificationsubscription'
146+
raise_exception = True
147+
model = NotificationSubscription
148+
149+
def get_queryset(self):
150+
qs = NotificationSubscription.objects.all().order_by(self.ordering)
151+
q = self.request.GET.get('q')
152+
if q:
153+
qs = qs.filter(
154+
Q(notification_type__name__icontains=q) |
155+
Q(user__username__icontains=q) |
156+
Q(message_frequency__icontains=q)
157+
)
158+
return qs
159+
160+
def get_context_data(self, **kwargs):
161+
context = super().get_context_data(**kwargs)
162+
q = self.request.GET.get('q', '')
163+
context['q'] = q
164+
# append search param to pagination links
165+
if q:
166+
context['extra_query_params'] = f"&q={q}"
167+
else:
168+
context['extra_query_params'] = ''
169+
context['subscriptions'] = context['object_list']
170+
context['page'] = context['page_obj']
171+
return context
172+
173+
class EmailTasksList(PermissionRequiredMixin, ListView):
174+
paginate_by = 25
175+
template_name = 'notifications/email_tasks_list.html'
176+
ordering = 'task_id'
177+
permission_required = 'osf.view_emailtask'
178+
raise_exception = True
179+
model = EmailTask
180+
181+
def get_queryset(self):
182+
qs = EmailTask.objects.all().order_by(self.ordering)
183+
q = self.request.GET.get('q')
184+
if q:
185+
qs = qs.filter(
186+
Q(task_id=q) |
187+
Q(user__username__icontains=q) |
188+
Q(status=q)
189+
)
190+
return qs
191+
192+
def get_context_data(self, **kwargs):
193+
context = super().get_context_data(**kwargs)
194+
q = self.request.GET.get('q', '')
195+
context['q'] = q
196+
# append search param to pagination links
197+
if q:
198+
context['extra_query_params'] = f"&q={q}"
199+
else:
200+
context['extra_query_params'] = ''
201+
context['email_tasks'] = context['object_list']
202+
context['page'] = context['page_obj']
203+
return context
204+
205+
class NotificationTypeList(PermissionRequiredMixin, ListView):
206+
paginate_by = 25
207+
template_name = 'notifications/notification_types_list.html'
208+
ordering = 'name'
209+
permission_required = 'osf.view_notificationtype'
210+
raise_exception = True
211+
model = NotificationType
212+
213+
def get_queryset(self):
214+
qs = NotificationType.objects.all().order_by(self.ordering)
215+
q = self.request.GET.get('q')
216+
if q:
217+
qs = qs.filter(
218+
Q(name__icontains=q) |
219+
Q(subject__icontains=q) |
220+
Q(notification_interval_choices__icontains=q)
221+
)
222+
return qs
223+
224+
def get_context_data(self, **kwargs):
225+
context = super().get_context_data(**kwargs)
226+
q = self.request.GET.get('q', '')
227+
context['q'] = q
228+
# append search param to pagination links
229+
if q:
230+
context['extra_query_params'] = f"&q={q}"
231+
else:
232+
context['extra_query_params'] = ''
233+
234+
context['notification_types'] = context['object_list']
235+
context['page'] = context['page_obj']
236+
return context
237+
238+
class NotificationTypeDisplay(PermissionRequiredMixin, DetailView):
239+
model = NotificationType
240+
template_name = 'notifications/notification_type_detail.html'
241+
permission_required = 'osf.view_notificationtype'
242+
raise_exception = True
243+
244+
def get_object(self, queryset=None):
245+
return NotificationType.objects.get(id=self.kwargs.get('pk'))
246+
247+
def get_context_data(self, *args, **kwargs):
248+
notification_type = self.get_object()
249+
notification_type_dict = model_to_dict(notification_type)
250+
fields = notification_type_dict.copy()
251+
kwargs.setdefault('page_number', self.request.GET.get('page', '1'))
252+
notification_type_dict['is_digest_type'] = notification_type.is_digest_type
253+
kwargs['notification_type'] = notification_type_dict
254+
kwargs['template'] = notification_type_dict.pop('template', None)
255+
kwargs['change_form'] = NotificationTypeForm(initial=fields)
256+
257+
return kwargs
258+
259+
class NotificationTypePreview(PermissionRequiredMixin, DetailView):
260+
model = NotificationType
261+
template_name = 'notifications/notification_type_preview.html'
262+
permission_required = 'osf.view_notificationtype'
263+
raise_exception = True
264+
265+
def get_object(self, queryset=None):
266+
return NotificationType.objects.get(id=self.kwargs.get('pk'))
267+
268+
def get_context_data(self, *args, **kwargs):
269+
notification_type = self.get_object()
270+
raw_context = self.request.GET.get('context')
271+
if raw_context:
272+
try:
273+
if notification_type.is_digest_type:
274+
safe_context = {'notifications': [json.loads(raw_context)]}
275+
else:
276+
safe_context = json.loads(raw_context)
277+
278+
return_context = json.loads(raw_context)
279+
except json.JSONDecodeError as e:
280+
kwargs['rendered_template'] = f"Error parsing JSON: {str(e)}"
281+
kwargs['context'] = raw_context
282+
return kwargs
283+
else:
284+
if notification_type.is_digest_type:
285+
inner_context = build_safe_context(notification_type.template)
286+
inner_template = _render_email_html(notification_type, ctx=inner_context, return_original_error=True)
287+
safe_context = {'notifications': [inner_template]}
288+
return_context = inner_context
289+
else:
290+
safe_context = build_safe_context(notification_type.template)
291+
return_context = safe_context
292+
293+
if notification_type.is_digest_type:
294+
# Use user_digest template as a wrapper for digest notification preview.
295+
template_obj = NotificationType.objects.get(name='user_digest')
296+
else:
297+
template_obj = notification_type
298+
try:
299+
kwargs['rendered_template'] = _render_email_html(template_obj, ctx=safe_context, return_original_error=True)
300+
except Exception as e:
301+
kwargs['rendered_template'] = f"Error rendering template: {str(e)}"
302+
303+
kwargs['context'] = json.dumps(return_context, indent=4)
304+
305+
return kwargs
306+
307+
class NotificationTypeDetail(PermissionRequiredMixin, DetailView):
308+
model = NotificationType
309+
template_name = 'notifications/notification_type_detail.html'
310+
permission_required = 'osf.view_notificationtype'
311+
raise_exception = True
312+
313+
def get(self, request, *args, **kwargs):
314+
view = NotificationTypeDetail.as_view()
315+
return view(request, *args, **kwargs)
316+
317+
def post(self, request, *args, **kwargs):
318+
view = NotificationTypeChangeForm.as_view()
319+
return view(request, *args, **kwargs)
320+
321+
class NotificationTypeChangeForm(PermissionRequiredMixin, UpdateView):
322+
template_name = 'institutions/detail.html'
323+
permission_required = 'osf.change_notificationtype'
324+
raise_exception = True
325+
model = NotificationType
326+
form_class = NotificationTypeForm
327+
328+
def get_success_url(self, *args, **kwargs):
329+
return reverse_lazy('notifications:type_display', kwargs={'pk': self.kwargs.get('pk')})

admin/templates/base.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,28 @@
289289
</div>
290290
{% endif %}
291291
{% endif %}
292+
{% if perms.osf.view_notification or perms.osf.view_notificationtype or perms.osf.view_notificationsubscription %}
293+
<li><a role="button" data-toggle="collapse" href="#collapseNotifications">
294+
<i class='fa fa-caret-down'></i> Notifications
295+
</a></li>
296+
<div class="collapse" id="collapseNotifications">
297+
<ul class="sidebar-menu sidebar-menu-inner">
298+
{% if perms.osf.view_notificationtype %}
299+
<li><a href="{% url 'notifications:types_list' %}"><i class='fa fa-link'></i> <span>Notification Types</span></a></li>
300+
{% endif %}
301+
{% if perms.osf.view_notification %}
302+
<li><a href="{% url 'notifications:list' %}"><i class='fa fa-link'></i> <span>Notifications</span></a></li>
303+
{% endif %}
304+
{% if perms.osf.view_notificationsubscription %}
305+
<li><a href="{% url 'notifications:subscriptions_list' %}"><i class='fa fa-link'></i> <span>Notification Subscriptions</span></a></li>
306+
{% endif %}
307+
{% if perms.osf.view_emailtask %}
308+
<li><a href="{% url 'notifications:email_tasks_list' %}"><i class='fa fa-link'></i> <span>Email Tasks</span></a></li>
309+
{% endif %}
310+
311+
</ul>
312+
</div>
313+
{% endif %}
292314
{% if perms.osf.view_metrics %}
293315
<li><a href="{% url 'metrics:metrics' %}"><i class='fa fa-link'></i> <span>Metrics</span></a></li>
294316
{% endif %}

0 commit comments

Comments
 (0)