Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

task/DSAPP-46, task/WP-611: Tools & Apps Workspace Changes; Notifications Enhancements #1442

Merged
merged 17 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion client/modules/_hooks/src/notifications/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import {
} from '@tanstack/react-query';
import apiClient from '../apiClient';

type TPortalEventType = 'data_depot' | 'job' | 'interactive_session_ready';
type TPortalEventType =
| 'data_depot'
| 'job'
| 'interactive_session_ready'
| 'markAllNotificationsAsRead';

export type TJobStatusNotification = {
action_link: string;
Expand Down
5 changes: 5 additions & 0 deletions client/modules/workspace/src/JobsListing/JobsListing.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useMemo, useState, useEffect } from 'react';
import useWebSocket from 'react-use-websocket';
import { TableProps, Row, Flex, Button as AntButton } from 'antd';
import type { ButtonSize } from 'antd/es/button';
import { useQueryClient } from '@tanstack/react-query';
Expand Down Expand Up @@ -96,12 +97,16 @@ export const JobsListing: React.FC<Omit<TableProps, 'columns'>> = ({
markRead: false,
});
const { mutate: readNotifications } = useReadNotifications();
const { sendMessage } = useWebSocket(
`wss://${window.location.host}/ws/websockets/`
);

// mark all as read on component mount
useEffect(() => {
readNotifications({
eventTypes: ['interactive_session_ready', 'job'],
});
sendMessage('markAllNotificationsAsRead');

// update unread count state
queryClient.setQueryData(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,11 @@ export const JobsListingTable: React.FC<
isLoading,
]);

const lastNotificationJobUUID = lastMessage
? (JSON.parse(lastMessage.data) as TJobStatusNotification).extra.uuid
: '';
const lastMessageJSON = lastMessage?.data
? (JSON.parse(lastMessage.data) as TJobStatusNotification)
: null;
const lastNotificationJobUUID =
lastMessageJSON?.event_type === 'job' ? lastMessageJSON.extra.uuid : '';
const unreadJobUUIDs = unreadNotifs?.notifs.map((x) => x.extra.uuid) ?? [];

/* RENDER THE TABLE */
Expand Down
10 changes: 10 additions & 0 deletions client/modules/workspace/src/Toast/Notifications.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
.root {
cursor: pointer;
background: #f4f4f4;
border: 1px solid #222222;
&:hover {
border-color: #5695c4;
background: #aac7ff;
}
}

.toast-is-error {
color: #eb6e6e;
}
38 changes: 33 additions & 5 deletions client/modules/workspace/src/Toast/Toast.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import React, { useEffect } from 'react';
import useWebSocket from 'react-use-websocket';
import { useQueryClient } from '@tanstack/react-query';
import { notification } from 'antd';
import { notification, Flex } from 'antd';
import { RightOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@client/common-components';
import { TJobStatusNotification } from '@client/hooks';
import {
TJobStatusNotification,
TGetNotificationsResponse,
} from '@client/hooks';
import { getToastMessage } from '../utils';
import styles from './Notifications.module.css';

Expand All @@ -31,19 +35,43 @@ const Notifications = () => {
queryKey: ['workspace', 'jobsListing'],
});
api.open({
message: getToastMessage(notification),
message: (
<Flex justify="space-between">
{getToastMessage(notification)}
<RightOutlined style={{ marginRight: -5 }} />
</Flex>
),
placement: 'bottomLeft',
icon: <Icon className={`ds-icon-Job-Status`} label="Job-Status" />,
className: `${
notification.extra.status === 'FAILED' && styles['toast-is-error']
}`,
} ${styles.root}`,
closeIcon: false,
duration: 5,
onClick: () => {
navigate('/history');
},
style: { cursor: 'pointer' },
});
} else if (notification.event_type === 'markAllNotificationsAsRead') {
// update unread count state
queryClient.setQueryData(
[
'workspace',
'notifications',
{
eventTypes: ['interactive_session_ready', 'job'],
read: false,
markRead: false,
},
],
(oldData: TGetNotificationsResponse) => {
return {
...oldData,
notifs: [],
unread: 0,
};
}
);
}
};

Expand Down
9 changes: 8 additions & 1 deletion client/src/workspace/workspaceRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ import { JobsListingLayout } from './layouts/JobsListingLayout';
import { AppsViewLayout } from './layouts/AppsViewLayout';
import { AppsPlaceholderLayout } from './layouts/AppsPlaceholderLayout';

const getBaseName = () => {
if (window.location.pathname.startsWith('/rw/workspace')) {
return '/rw/workspace';
}
return '/workspace';
};

const workspaceRouter = createBrowserRouter(
[
{
Expand Down Expand Up @@ -44,7 +51,7 @@ const workspaceRouter = createBrowserRouter(
],
},
],
{ basename: '/rw/workspace' }
{ basename: getBaseName() }
);

export default workspaceRouter;
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ <h1 class="headline headline-research">{{title}}</h1>
<div class="col-sm-3">
<ul class="nav nav-pills nav-stacked">
{% url 'designsafe_accounts:manage_profile' as item_url %}
<li {% if item_url in request.path %}class="active"{% endif %}><a href="{{item_url}}">Account Profile</a></li>
<li {% if item_url in request.path %}class="active"{% endif %}><a href="{{item_url}}">Manage Account</a></li>

{% url 'designsafe_accounts:manage_authentication' as item_url %}
<li {% if request.path == item_url %}class="active"{% endif %}><a href="{{item_url}}">Authentication</a></li>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{% extends "designsafe/apps/accounts/base.html" %}

{% block title %}Account Profile{% endblock %}
{% block title %}Manage Account{% endblock %}

{% block panel_content %}
<div class="panel panel-default">
<div class="panel-body">
<h2>Account Profile</h2>
<h2>Manage Account</h2>
<hr>
<div class="row">
<div class="col-md-6">
Expand Down
2 changes: 1 addition & 1 deletion designsafe/apps/accounts/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def menu_items(**kwargs):
if 'type' in kwargs and kwargs['type'] == 'account':
return [
{
'label': _('Account Profile'),
'label': _('Manage Account'),
'url': reverse('designsafe_accounts:index'),
'children': [],
},
Expand Down
4 changes: 2 additions & 2 deletions designsafe/apps/accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def manage_profile(request):
logger.info('exception e:{} {}'.format(type(e), e))

context = {
'title': 'Account Profile',
'title': 'Manage Account',
'profile': user_profile,
'ds_profile': ds_profile,
'demographics': demographics,
Expand Down Expand Up @@ -353,7 +353,7 @@ def profile_edit(request):
form = forms.UserProfileForm(initial=tas_user)

context = {
'title': 'Account Profile',
'title': 'Manage Account',
'form': form,
'pro_form': pro_form
}
Expand Down
63 changes: 29 additions & 34 deletions designsafe/apps/api/notifications/receivers.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,47 @@
"""Signal receivers for notifications"""

import logging
import json
from django.db.models.signals import post_save
from django.dispatch import receiver
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db.models.signals import post_save
from designsafe.apps.api.notifications.models import Notification, Broadcast
import logging
import json

logger = logging.getLogger(__name__)

WEBSOCKETS_FACILITY = 'websockets'
WEBSOCKETS_FACILITY = "websockets"


@receiver(post_save, sender=Notification, dispatch_uid="notification_msg")
def send_notification_ws(instance, created, **kwargs):
"""Send a websocket message to the user when a new notification is created."""

@receiver(post_save, sender=Notification, dispatch_uid='notification_msg')
def send_notification_ws(sender, instance, created, **kwargs):
#Only send WS message if it's a new notification not if we're updating.
logger.debug('receiver received something.')
if not created:
return
try:
channel_layer = get_channel_layer()
instance_dict = json.dumps(instance.to_dict())
logger.debug(instance_dict)

async_to_sync(channel_layer.group_send)(f"ds_{instance.user}", {"type": "ds.notification", "message": instance_dict})
channel_layer = get_channel_layer()
instance_dict = json.dumps(instance.to_dict())

async_to_sync(channel_layer.group_send)(
f"ds_{instance.user}", {"type": "ds.notification", "message": instance_dict}
)

# logger.debug('WS socket msg sent: {}'.format(instance_dict))
except Exception as e:
# logger.debug('Exception sending websocket message',
# exc_info=True,
# extra = instance.to_dict())
logger.debug('Exception sending websocket message',
exc_info=True)
return

@receiver(post_save, sender=Broadcast, dispatch_uid='broadcast_msg')
def send_broadcast_ws(sender, instance, created, **kwargs):

@receiver(post_save, sender=Broadcast, dispatch_uid="broadcast_msg")
def send_broadcast_ws(instance, created, **kwargs):
"""Send a websocket message to all users when a new broadcast is created."""

if not created:
return
try:
event_type, user, body = decompose_message(instance)
#rp = RedisPublisher(facility = WEBSOCKETS_FACILITY,broadcast=True)
channel_layer = get_channel_layer()
instance_dict = json.dumps(instance.to_dict())

async_to_sync(channel_layer.group_send)("ds_broadcast", {"type": "ds.notification", "message": instance_dict})
logger.debug('WS socket msg sent: {}'.format(instance_dict))
except Exception as e:
logger.debug('Exception sending websocket message',
exc_info=True,
extra = instance.to_dict())

channel_layer = get_channel_layer()
instance_dict = json.dumps(instance.to_dict())

async_to_sync(channel_layer.group_send)(
"ds_broadcast", {"type": "ds.notification", "message": instance_dict}
)

return
33 changes: 21 additions & 12 deletions designsafe/apps/signals/websocket_consumers.py
Original file line number Diff line number Diff line change
@@ -1,28 +1,37 @@
import json
"""Websocket consumers"""

from channels.generic.websocket import WebsocketConsumer, AsyncWebsocketConsumer
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.layers import get_channel_layer


class DesignsafeWebsocketConsumer(AsyncWebsocketConsumer):
"""Websocket consumer for DesignSafe notifications"""

async def connect(self):
self.user_channel = f"ds_{self.scope['user']}"
self.broadcast_channel = "ds_broadcast"
await self.channel_layer.group_add(
self.user_channel, self.channel_name
f"ds_{self.scope['user']}", self.channel_name
)
await self.accept()

async def disconnect(self, close_code):
await self.channel_layer.group_discard(
self.user_channel, self.channel_name
)
async def disconnect(self, code):
await self.channel_layer.group_discard(
self.broadcast_channel, self.channel_name
f"ds_{self.scope['user']}", self.channel_name
)
await self.channel_layer.group_discard("ds_broadcast", self.channel_name)

async def receive(self, text_data):
pass
async def receive(self, text_data=None, bytes_data=None):
if text_data == "markAllNotificationsAsRead":
channel_layer = get_channel_layer()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user sends this signal then refreshes the page, do the "unread" notifications come back? I don't see anything that persists read/unread state to the db.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes the mark read state is persisted already via the requests, so these websocket messages only need to communicate that to each other.

await channel_layer.group_send(
f"ds_{self.scope['user']}",
{
"type": "ds_notification",
"message": json.dumps({"event_type": "markAllNotificationsAsRead"}),
},
)

async def ds_notification(self, event):
"""Send notification to user"""
message = event["message"]
await self.send(text_data=message)
4 changes: 2 additions & 2 deletions designsafe/apps/workspace/models/app_entries.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,8 +233,8 @@ def href(self):
"""Retrieve the app's URL in the Tools & Applications space"""
if self.external_href:
return self.external_href
app_href = f"/rw/workspace/{self.app_id}"

app_href = f"/workspace/{self.app_id}"
if self.version:
app_href += f"?appVersion={self.version}"
return app_href
Expand Down
1 change: 1 addition & 0 deletions designsafe/apps/workspace/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
from designsafe.apps.workspace import views

urlpatterns = [
re_path('history', views.WorkspaceView.as_view(), name="history"),
re_path('^', views.WorkspaceView.as_view(), name="workspace"),
]
4 changes: 2 additions & 2 deletions designsafe/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class DesignsafeProfileUpdateMiddleware(MiddlewareMixin):

def process_request(self, request):
blocked_path = request.path.startswith(
("/data", "/applications", "/rw/workspace", "/recon-portal", "/dashboard")
("/data", "/applications", "/rw/workspace", "/workspace", "/recon-portal", "/dashboard")
)
if request.user.is_authenticated and request.user.profile.update_required and blocked_path:
messages.warning(
Expand Down Expand Up @@ -65,7 +65,7 @@ def process_request(self, request):
'Use is required for continued use of DesignSafe '
'resources.' % accept_url)
return None


class SiteMessageMiddleware(MiddlewareMixin):
def process_request(self, request):
Expand Down
2 changes: 1 addition & 1 deletion designsafe/settings/common_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,7 @@
'.java', '.js', '.less', '.m', '.make', '.md', '.ml', '.mm', '.msg', '.php',
'.pl', '.properties', '.py', '.rb', '.sass', '.scala', '.script', '.sh', '.sml',
'.sql', '.txt', '.vi', '.vim', '.xml', '.xsd', '.xsl', '.yaml', '.yml', '.tcl',
'.json', '.out', '.err', '.geojson', '.do', '.sas', '.hazmapper'
'.json', '.out', '.err', '.geojson', '.do', '.sas', '.hazmapper', ".log"
]

SUPPORTED_OBJECT_PREVIEW_EXTS = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
<div> <span> Quick Links </span> </div>
<div><a href="/account"> Manage Account </a> </div>
<div> <a href="/data/browser"> Data Depot </a> </div>
<div> <a href="/rw/workspace"> Tools & Applications </a> </div>
<div> <a href="/workspace"> Tools & Applications </a> </div>
<div> <a href="/recon-portal"> Recon Portal </a> </div>
<div> <a href="/learning-center/overview"> Training </a> </div>
</div>
Expand Down
Loading
Loading