From 0b47028d4c1cb5b768a01ed4da5ee942dfbd6798 Mon Sep 17 00:00:00 2001 From: Uditi Mehta Date: Fri, 16 Aug 2024 16:55:45 -0400 Subject: [PATCH 1/4] fix issues identified after PR merge --- admin/notifications/views.py | 24 ++++++++++----- .../handle_duplicate_notifications.html | 29 +++++++++++++++---- admin_tests/notifications/test_views.py | 14 ++++----- .../commands/create_test_notifications.py | 12 +++++--- 4 files changed, 53 insertions(+), 26 deletions(-) diff --git a/admin/notifications/views.py b/admin/notifications/views.py index aa152f418ce..06954169962 100644 --- a/admin/notifications/views.py +++ b/admin/notifications/views.py @@ -1,5 +1,6 @@ from django.contrib.auth.decorators import user_passes_test from django.shortcuts import render, redirect +from django.core.paginator import Paginator from admin.base.utils import osf_staff_check from osf.models.notifications import NotificationSubscription from django.db.models import Count @@ -9,29 +10,32 @@ def delete_selected_notifications(selected_ids): def detect_duplicate_notifications(): duplicates = ( - NotificationSubscription.objects.values('user', 'node', 'event_name') - .annotate(count=Count('id')) + NotificationSubscription.objects.values('_id') + .annotate(count=Count('_id')) .filter(count__gt=1) ) detailed_duplicates = [] for dup in duplicates: notifications = NotificationSubscription.objects.filter( - user=dup['user'], node=dup['node'], event_name=dup['event_name'] + _id=dup['_id'] ).order_by('created') - for notification in notifications: + if notifications.exists(): + notification = notifications.first() detailed_duplicates.append({ 'id': notification.id, - 'user': notification.user, - 'node': notification.node, + '_id': notification._id, 'event_name': notification.event_name, 'created': notification.created, - 'count': dup['count'] + 'count': dup['count'], + 'email_transactional': [u._id for u in notification.email_transactional.all()], + 'email_digest': [u._id for u in notification.email_digest.all()] }) return detailed_duplicates + def process_duplicate_notifications(request): detailed_duplicates = detect_duplicate_notifications() @@ -46,7 +50,11 @@ def process_duplicate_notifications(request): def handle_duplicate_notifications(request): detailed_duplicates, message, is_post = process_duplicate_notifications(request) - context = {'duplicates': detailed_duplicates} + paginator = Paginator(detailed_duplicates, 20) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + context = {'duplicates': page_obj} if is_post: context['message'] = message return redirect('notifications:handle_duplicate_notifications') diff --git a/admin/templates/notifications/handle_duplicate_notifications.html b/admin/templates/notifications/handle_duplicate_notifications.html index de88ff24d8f..50a8a4a1aec 100644 --- a/admin/templates/notifications/handle_duplicate_notifications.html +++ b/admin/templates/notifications/handle_duplicate_notifications.html @@ -22,32 +22,51 @@

Duplicate Notifications

Select - User - Node + ID Event Name Created Count + Email Transactional + Email Digest {% for notification in duplicates %} - {{ notification.user }} - {{ notification.node }} + {{ notification.notification_id }} {{ notification.event_name }} {{ notification.created }} {{ notification.count }} + {{ notification.email_transactional|join:", " }} + {{ notification.email_digest|join:", " }} {% empty %} - No duplicate notifications found! + No duplicate notifications found! {% endfor %} + {% else %}

No duplicate notifications found.

{% endif %} diff --git a/admin_tests/notifications/test_views.py b/admin_tests/notifications/test_views.py index 37d0bcc4432..c37ddf02158 100644 --- a/admin_tests/notifications/test_views.py +++ b/admin_tests/notifications/test_views.py @@ -14,6 +14,7 @@ class TestNotificationFunctions(AdminTestCase): def setUp(self): super().setUp() + NotificationSubscription.objects.all().delete() # Clear out existing data self.user = OSFUser.objects.create(username='admin', is_staff=True) self.node = Node.objects.create(creator=self.user, title='Test Node') self.request_factory = RequestFactory() @@ -29,20 +30,15 @@ def test_delete_selected_notifications(self): assert NotificationSubscription.objects.filter(id=notification3.id).exists() def test_detect_duplicate_notifications(self): + NotificationSubscription.objects.all().delete() + NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') - NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event2') duplicates = detect_duplicate_notifications() - assert len(duplicates) == 2 - assert duplicates[0]['user'] == self.user - assert duplicates[0]['node'] == self.node - assert duplicates[0]['event_name'] == 'event1' - assert duplicates[0]['count'] == 2 - - non_duplicate_event = [dup for dup in duplicates if dup['event_name'] == 'event2'] - assert len(non_duplicate_event) == 0 + assert len(duplicates) == 1, f'Expected 1 duplicate, found {len(duplicates)}' + assert duplicates[0]['count'] == 2, 'Expected count to be 2 for the detected duplicate' def test_process_duplicate_notifications_get(self): request = self.request_factory.get('/fake_path') diff --git a/osf/management/commands/create_test_notifications.py b/osf/management/commands/create_test_notifications.py index 8d3549c3f10..a5345225255 100644 --- a/osf/management/commands/create_test_notifications.py +++ b/osf/management/commands/create_test_notifications.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand from osf.models.notifications import NotificationSubscription from osf.models import OSFUser, Node -from django.utils.crypto import get_random_string from django.utils import timezone class Command(BaseCommand): @@ -12,16 +11,21 @@ def handle(self, *args, **kwargs): node = Node.objects.first() event_name = 'file_added' + if not user or not node: + self.stdout.write(self.style.ERROR('User or Node not found. Please ensure they exist in the database.')) + return + + duplicate_id = f'{node._id}_{event_name}' + for _ in range(3): - unique_id = get_random_string(length=32) notification = NotificationSubscription.objects.create( user=user, node=node, event_name=event_name, - _id=unique_id, + _id=duplicate_id, created=timezone.now() ) notification.email_transactional.add(user) notification.save() - self.stdout.write(self.style.SUCCESS('Successfully created duplicate notifications')) + self.stdout.write(self.style.SUCCESS('Successfully created duplicate notifications with the same _id')) From 63549849f5482fb662190dcbb32b884fe8f98455 Mon Sep 17 00:00:00 2001 From: Uditi Mehta Date: Wed, 21 Aug 2024 12:07:00 -0400 Subject: [PATCH 2/4] move duplicate notification handling to node detail page --- admin/nodes/views.py | 25 ++++++- admin/notifications/views.py | 35 ++++----- admin/templates/base.html | 3 - admin/templates/nodes/node.html | 44 +++++++++++ .../handle_duplicate_notifications.html | 73 ------------------- admin_tests/notifications/test_views.py | 9 +-- .../commands/create_test_notifications.py | 10 +-- 7 files changed, 90 insertions(+), 109 deletions(-) delete mode 100644 admin/templates/notifications/handle_duplicate_notifications.html diff --git a/admin/nodes/views.py b/admin/nodes/views.py index e7902956add..352fdfb59f2 100644 --- a/admin/nodes/views.py +++ b/admin/nodes/views.py @@ -21,6 +21,7 @@ from admin.base.utils import change_embargo_date, validate_embargo_date from admin.base.views import GuidView from admin.base.forms import GuidForm +from admin.notifications.views import detect_duplicate_notifications, delete_selected_notifications from api.share.utils import update_share from api.caching.tasks import update_storage_usage_cache @@ -92,11 +93,29 @@ class NodeView(NodeMixin, GuidView): raise_exception = True def get_context_data(self, **kwargs): - return super().get_context_data(**{ + context = super().get_context_data(**kwargs) + node = self.get_object() + + detailed_duplicates = detect_duplicate_notifications(node_id=node.id) + + context.update({ 'SPAM_STATUS': SpamStatus, 'STORAGE_LIMITS': settings.StorageLimits, - 'node': kwargs.pop('object', self.get_object()), - }, **kwargs) + 'node': node, + 'duplicates': detailed_duplicates + }) + + return context + + def post(self, request, *args, **kwargs): + selected_ids = request.POST.getlist('selected_notifications') + if selected_ids: + delete_selected_notifications(selected_ids) + messages.success(request, 'Selected notifications were successfully deleted.') + else: + messages.error(request, 'No notifications selected for deletion.') + + return redirect(self.get_success_url()) class NodeSearchView(PermissionRequiredMixin, FormView): diff --git a/admin/notifications/views.py b/admin/notifications/views.py index 06954169962..5c55f6cfe65 100644 --- a/admin/notifications/views.py +++ b/admin/notifications/views.py @@ -1,6 +1,6 @@ from django.contrib.auth.decorators import user_passes_test from django.shortcuts import render, redirect -from django.core.paginator import Paginator +from django.urls import reverse from admin.base.utils import osf_staff_check from osf.models.notifications import NotificationSubscription from django.db.models import Count @@ -8,21 +8,18 @@ def delete_selected_notifications(selected_ids): NotificationSubscription.objects.filter(id__in=selected_ids).delete() -def detect_duplicate_notifications(): - duplicates = ( - NotificationSubscription.objects.values('_id') - .annotate(count=Count('_id')) - .filter(count__gt=1) - ) +def detect_duplicate_notifications(node_id=None): + query = NotificationSubscription.objects.values('_id').annotate(count=Count('_id')).filter(count__gt=1) + if node_id: + query = query.filter(node_id=node_id) detailed_duplicates = [] - for dup in duplicates: + for dup in query: notifications = NotificationSubscription.objects.filter( _id=dup['_id'] ).order_by('created') - if notifications.exists(): - notification = notifications.first() + for notification in notifications: detailed_duplicates.append({ 'id': notification.id, '_id': notification._id, @@ -30,14 +27,15 @@ def detect_duplicate_notifications(): 'created': notification.created, 'count': dup['count'], 'email_transactional': [u._id for u in notification.email_transactional.all()], - 'email_digest': [u._id for u in notification.email_digest.all()] + 'email_digest': [u._id for u in notification.email_digest.all()], + 'none': [u._id for u in notification.none.all()] }) return detailed_duplicates -def process_duplicate_notifications(request): - detailed_duplicates = detect_duplicate_notifications() +def process_duplicate_notifications(request, node_id=None): + detailed_duplicates = detect_duplicate_notifications(node_id) if request.method == 'POST': selected_ids = request.POST.getlist('selected_notifications') @@ -48,15 +46,12 @@ def process_duplicate_notifications(request): @user_passes_test(osf_staff_check) def handle_duplicate_notifications(request): - detailed_duplicates, message, is_post = process_duplicate_notifications(request) + node_id = request.GET.get('node_id') + detailed_duplicates, message, is_post = process_duplicate_notifications(request, node_id) - paginator = Paginator(detailed_duplicates, 20) - page_number = request.GET.get('page') - page_obj = paginator.get_page(page_number) - - context = {'duplicates': page_obj} + context = {'duplicates': detailed_duplicates, 'node_id': node_id} if is_post: context['message'] = message - return redirect('notifications:handle_duplicate_notifications') + return redirect(f"{reverse('notifications:handle_duplicate_notifications')}?node_id={node_id}") return render(request, 'notifications/handle_duplicate_notifications.html', context) diff --git a/admin/templates/base.html b/admin/templates/base.html index 40a91221f88..952fc088bc5 100644 --- a/admin/templates/base.html +++ b/admin/templates/base.html @@ -311,9 +311,6 @@ {% if perms.osf.change_maintenancestate %}
  • Maintenance Alerts
  • {% endif %} - {% if perms.osf.view_notification %} -
  • Duplicate Notifications
  • - {% endif %} diff --git a/admin/templates/nodes/node.html b/admin/templates/nodes/node.html index 2a410881666..5a9bcfad701 100644 --- a/admin/templates/nodes/node.html +++ b/admin/templates/nodes/node.html @@ -104,6 +104,50 @@

    {{ node.type|cut:'osf.'|title }}: {{ node.title }} + +

    Duplicate Notifications

    + {% if duplicates %} +
    + {% csrf_token %} + + + + + + + + + + + + + + {% for notification in duplicates %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
    SelectEvent NameCreatedCountEmail TransactionalEmail DigestNone
    {{ notification.event_name }}{{ notification.created }}{{ notification.count }}{{ notification.email_transactional|join:", " }}{{ notification.email_digest|join:", " }}{{ notification.none|join:", " }}
    No duplicate notifications found!
    + +
    + {% else %} +

    No duplicate notifications found.

    + {% endif %} + + diff --git a/admin/templates/notifications/handle_duplicate_notifications.html b/admin/templates/notifications/handle_duplicate_notifications.html deleted file mode 100644 index 50a8a4a1aec..00000000000 --- a/admin/templates/notifications/handle_duplicate_notifications.html +++ /dev/null @@ -1,73 +0,0 @@ -{% extends "base.html" %} -{% load render_bundle from webpack_loader %} -{% load static %} - -{% block title %} - Duplicate Notifications -{% endblock title %} - -{% block content %} -

    Duplicate Notifications

    - - {% if message %} -
    - {{ message }} -
    - {% endif %} - - {% if duplicates %} -
    - {% csrf_token %} - - - - - - - - - - - - - - {% for notification in duplicates %} - - - - - - - - - - {% empty %} - - - - {% endfor %} - -
    SelectIDEvent NameCreatedCountEmail TransactionalEmail Digest
    {{ notification.notification_id }}{{ notification.event_name }}{{ notification.created }}{{ notification.count }}{{ notification.email_transactional|join:", " }}{{ notification.email_digest|join:", " }}
    No duplicate notifications found!
    - -
    -
    - {% else %} -

    No duplicate notifications found.

    - {% endif %} -{% endblock content %} diff --git a/admin_tests/notifications/test_views.py b/admin_tests/notifications/test_views.py index c37ddf02158..6647b235219 100644 --- a/admin_tests/notifications/test_views.py +++ b/admin_tests/notifications/test_views.py @@ -14,7 +14,6 @@ class TestNotificationFunctions(AdminTestCase): def setUp(self): super().setUp() - NotificationSubscription.objects.all().delete() # Clear out existing data self.user = OSFUser.objects.create(username='admin', is_staff=True) self.node = Node.objects.create(creator=self.user, title='Test Node') self.request_factory = RequestFactory() @@ -30,15 +29,15 @@ def test_delete_selected_notifications(self): assert NotificationSubscription.objects.filter(id=notification3.id).exists() def test_detect_duplicate_notifications(self): - NotificationSubscription.objects.all().delete() - NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') + NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event2') duplicates = detect_duplicate_notifications() - assert len(duplicates) == 1, f'Expected 1 duplicate, found {len(duplicates)}' - assert duplicates[0]['count'] == 2, 'Expected count to be 2 for the detected duplicate' + print(f"Detected duplicates: {duplicates}") + + assert len(duplicates) == 3, f"Expected 3 duplicates, but found {len(duplicates)}" def test_process_duplicate_notifications_get(self): request = self.request_factory.get('/fake_path') diff --git a/osf/management/commands/create_test_notifications.py b/osf/management/commands/create_test_notifications.py index a5345225255..5006cc30676 100644 --- a/osf/management/commands/create_test_notifications.py +++ b/osf/management/commands/create_test_notifications.py @@ -4,18 +4,18 @@ from django.utils import timezone class Command(BaseCommand): - help = 'Create duplicate notifications for testing' + help = 'Create duplicate notifications for testing for a specific node' def handle(self, *args, **kwargs): user = OSFUser.objects.first() - node = Node.objects.first() - event_name = 'file_added' + node = Node.objects.filter(guids___id='3ura2').first() + event_name = 'event1' if not user or not node: self.stdout.write(self.style.ERROR('User or Node not found. Please ensure they exist in the database.')) return - duplicate_id = f'{node._id}_{event_name}' + duplicate_id = f'{node.id}_{event_name}' for _ in range(3): notification = NotificationSubscription.objects.create( @@ -28,4 +28,4 @@ def handle(self, *args, **kwargs): notification.email_transactional.add(user) notification.save() - self.stdout.write(self.style.SUCCESS('Successfully created duplicate notifications with the same _id')) + self.stdout.write(self.style.SUCCESS(f'Successfully created duplicate notifications with the same _id for node {node.id}')) From 91194f077f921ac6e18a8c4b8ed724f3604c0f62 Mon Sep 17 00:00:00 2001 From: Uditi Mehta Date: Wed, 21 Aug 2024 16:41:45 -0400 Subject: [PATCH 3/4] Move notification deletion to a dedicated view and remove obsolete duplicate notification handling logic. --- admin/base/urls.py | 1 - admin/nodes/urls.py | 1 + admin/nodes/views.py | 4 ++-- admin/notifications/urls.py | 8 -------- admin/notifications/views.py | 27 ------------------------- admin/templates/nodes/node.html | 2 +- admin_tests/notifications/test_views.py | 25 ----------------------- 7 files changed, 4 insertions(+), 64 deletions(-) delete mode 100644 admin/notifications/urls.py diff --git a/admin/base/urls.py b/admin/base/urls.py index f46c5afa240..765e3161645 100644 --- a/admin/base/urls.py +++ b/admin/base/urls.py @@ -36,7 +36,6 @@ re_path(r'^schema_responses/', include('admin.schema_responses.urls', namespace='schema_responses')), re_path(r'^registration_schemas/', include('admin.registration_schemas.urls', namespace='registration_schemas')), re_path(r'^cedar_metadata_templates/', include('admin.cedar.urls', namespace='cedar_metadata_templates')), - re_path(r'^notifications/', include('admin.notifications.urls', namespace='notifications')), ]), ), ] diff --git a/admin/nodes/urls.py b/admin/nodes/urls.py index 6a918831f4c..5036b9dd06d 100644 --- a/admin/nodes/urls.py +++ b/admin/nodes/urls.py @@ -37,4 +37,5 @@ name='recalculate-node-storage'), re_path(r'^(?P[a-z0-9]+)/make_private/$', views.NodeMakePrivate.as_view(), name='make-private'), re_path(r'^(?P[a-z0-9]+)/make_public/$', views.NodeMakePublic.as_view(), name='make-public'), + re_path(r'^(?P[a-z0-9]+)/remove_notifications/$', views.NodeRemoveNotificationView.as_view(), name='node-remove-notifications'), ] diff --git a/admin/nodes/views.py b/admin/nodes/views.py index 352fdfb59f2..74b6b08feae 100644 --- a/admin/nodes/views.py +++ b/admin/nodes/views.py @@ -107,6 +107,7 @@ def get_context_data(self, **kwargs): return context +class NodeRemoveNotificationView(View): def post(self, request, *args, **kwargs): selected_ids = request.POST.getlist('selected_notifications') if selected_ids: @@ -115,8 +116,7 @@ def post(self, request, *args, **kwargs): else: messages.error(request, 'No notifications selected for deletion.') - return redirect(self.get_success_url()) - + return redirect('nodes:node', guid=kwargs.get('guid')) class NodeSearchView(PermissionRequiredMixin, FormView): """ Allows authorized users to search for a node by it's guid. diff --git a/admin/notifications/urls.py b/admin/notifications/urls.py deleted file mode 100644 index 7442d33eff3..00000000000 --- a/admin/notifications/urls.py +++ /dev/null @@ -1,8 +0,0 @@ -from django.urls import re_path -from admin.notifications import views - -app_name = 'notifications' - -urlpatterns = [ - re_path(r'^$', views.handle_duplicate_notifications, name='handle_duplicate_notifications'), -] diff --git a/admin/notifications/views.py b/admin/notifications/views.py index 5c55f6cfe65..7a3a13a8df8 100644 --- a/admin/notifications/views.py +++ b/admin/notifications/views.py @@ -1,7 +1,3 @@ -from django.contrib.auth.decorators import user_passes_test -from django.shortcuts import render, redirect -from django.urls import reverse -from admin.base.utils import osf_staff_check from osf.models.notifications import NotificationSubscription from django.db.models import Count @@ -32,26 +28,3 @@ def detect_duplicate_notifications(node_id=None): }) return detailed_duplicates - - -def process_duplicate_notifications(request, node_id=None): - detailed_duplicates = detect_duplicate_notifications(node_id) - - if request.method == 'POST': - selected_ids = request.POST.getlist('selected_notifications') - delete_selected_notifications(selected_ids) - return detailed_duplicates, 'Selected duplicate notifications have been deleted.', True - - return detailed_duplicates, '', False - -@user_passes_test(osf_staff_check) -def handle_duplicate_notifications(request): - node_id = request.GET.get('node_id') - detailed_duplicates, message, is_post = process_duplicate_notifications(request, node_id) - - context = {'duplicates': detailed_duplicates, 'node_id': node_id} - if is_post: - context['message'] = message - return redirect(f"{reverse('notifications:handle_duplicate_notifications')}?node_id={node_id}") - - return render(request, 'notifications/handle_duplicate_notifications.html', context) diff --git a/admin/templates/nodes/node.html b/admin/templates/nodes/node.html index 5a9bcfad701..6ec71e2dfdc 100644 --- a/admin/templates/nodes/node.html +++ b/admin/templates/nodes/node.html @@ -109,7 +109,7 @@

    {{ node.type|cut:'osf.'|title }}: {{ node.title }}

    Duplicate Notifications

    {% if duplicates %} -
    + {% csrf_token %} diff --git a/admin_tests/notifications/test_views.py b/admin_tests/notifications/test_views.py index 6647b235219..08ad695edd1 100644 --- a/admin_tests/notifications/test_views.py +++ b/admin_tests/notifications/test_views.py @@ -4,7 +4,6 @@ from admin.notifications.views import ( delete_selected_notifications, detect_duplicate_notifications, - process_duplicate_notifications ) from tests.base import AdminTestCase @@ -38,27 +37,3 @@ def test_detect_duplicate_notifications(self): print(f"Detected duplicates: {duplicates}") assert len(duplicates) == 3, f"Expected 3 duplicates, but found {len(duplicates)}" - - def test_process_duplicate_notifications_get(self): - request = self.request_factory.get('/fake_path') - request.user = self.user - - detailed_duplicates, message, is_post = process_duplicate_notifications(request) - - assert detailed_duplicates == [] - assert message == '' - assert not is_post - - def test_process_duplicate_notifications_post(self): - notification1 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') - notification2 = NotificationSubscription.objects.create(user=self.user, node=self.node, event_name='event1') - - request = self.request_factory.post('/fake_path', {'selected_notifications': [notification1.id]}) - request.user = self.user - - detailed_duplicates, message, is_post = process_duplicate_notifications(request) - - assert message == 'Selected duplicate notifications have been deleted.' - assert is_post - assert not NotificationSubscription.objects.filter(id=notification1.id).exists() - assert NotificationSubscription.objects.filter(id=notification2.id).exists() From 7ee3ba183ab37057036831ce3f5001648b0f65f5 Mon Sep 17 00:00:00 2001 From: Uditi Mehta Date: Thu, 22 Aug 2024 09:02:11 -0400 Subject: [PATCH 4/4] remove command file --- .../commands/create_test_notifications.py | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 osf/management/commands/create_test_notifications.py diff --git a/osf/management/commands/create_test_notifications.py b/osf/management/commands/create_test_notifications.py deleted file mode 100644 index 5006cc30676..00000000000 --- a/osf/management/commands/create_test_notifications.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.core.management.base import BaseCommand -from osf.models.notifications import NotificationSubscription -from osf.models import OSFUser, Node -from django.utils import timezone - -class Command(BaseCommand): - help = 'Create duplicate notifications for testing for a specific node' - - def handle(self, *args, **kwargs): - user = OSFUser.objects.first() - node = Node.objects.filter(guids___id='3ura2').first() - event_name = 'event1' - - if not user or not node: - self.stdout.write(self.style.ERROR('User or Node not found. Please ensure they exist in the database.')) - return - - duplicate_id = f'{node.id}_{event_name}' - - for _ in range(3): - notification = NotificationSubscription.objects.create( - user=user, - node=node, - event_name=event_name, - _id=duplicate_id, - created=timezone.now() - ) - notification.email_transactional.add(user) - notification.save() - - self.stdout.write(self.style.SUCCESS(f'Successfully created duplicate notifications with the same _id for node {node.id}'))