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

Add email notifications channel #17914

Merged
merged 31 commits into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e551e60
Allow to send notifications from Celery
davelopez Mar 11, 2024
13d9fef
Use NotificationService instead of manager in other services
davelopez Mar 11, 2024
d29f6db
Update NotificationCreateRequest definition
davelopez Mar 11, 2024
f2641d1
Add email templates for message notifications
davelopez Apr 3, 2024
a6ba1a8
Add email templates for shared item notifications
davelopez Apr 3, 2024
81ae5f4
Add EmailNotificationChannelPlugin and basic email sending
davelopez Apr 3, 2024
fd7bd1b
Add db revision for notifications dispatched column
davelopez Apr 4, 2024
3fe77fb
Add dispatched column to notification model
davelopez Apr 4, 2024
dda46db
Adapt notification model mapped types
davelopez Apr 4, 2024
1ec4d42
Ignore some inaccurate types from model mappings
davelopez Apr 4, 2024
8a62db2
Refactor notifications manager to send via other channels
davelopez Apr 4, 2024
a4e22b4
Add celery task to dispatch pending notifications
davelopez Apr 4, 2024
dbdd5bc
Add dispatch_notifications_interval config option
davelopez Apr 5, 2024
9c2d569
Add dispatch_pending_notifications to periodic tasks
davelopez Apr 5, 2024
bb6f688
Schedule periodic notifications tasks only when enabled
davelopez Apr 5, 2024
349c373
Run notification integration tests without celery
davelopez Apr 5, 2024
8b4b385
Revert utcnow refactoring
davelopez Apr 5, 2024
871ccc6
Update client schema
davelopez Apr 5, 2024
1e94458
Handle channel settings depending on server config
davelopez Apr 5, 2024
f43b385
Remove redundant nullable in dispatched column
davelopez Apr 6, 2024
452669d
Fix generic types in notifications schema
davelopez Apr 8, 2024
ba0a0ab
Refactor `GenericModel` for reusability
davelopez Apr 9, 2024
11ca547
Allow to `force_sync` for creating internal notifications
davelopez Apr 9, 2024
123904a
Adapt notification integration tests to work w/o tasks
davelopez Apr 9, 2024
89d5af4
Add db migration for galaxy_url column in notifications table
davelopez Apr 11, 2024
f083768
Add galaxy_url mapped column to notification model
davelopez Apr 11, 2024
34461f2
Update notification creation and sharing logic
davelopez Apr 11, 2024
de1cf8f
Update notification email templates
davelopez Apr 11, 2024
00d9865
Open notification preferences if specified in route query
davelopez Apr 11, 2024
0a287f2
Use proper type for Mapped
davelopez Apr 11, 2024
23164de
Apply suggestions from code review
davelopez Apr 16, 2024
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
15 changes: 11 additions & 4 deletions client/src/api/notifications.preferences.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { type components, fetcher } from "@/api/schema";

export type UserNotificationPreferences = components["schemas"]["UserNotificationPreferences"];
type UserNotificationPreferences = components["schemas"]["UserNotificationPreferences"];

export interface UserNotificationPreferencesExtended extends UserNotificationPreferences {
supportedChannels: string[];
}

const getNotificationsPreferences = fetcher.path("/api/notifications/preferences").method("get").create();
export async function getNotificationsPreferencesFromServer() {
const { data } = await getNotificationsPreferences({});
return data;
export async function getNotificationsPreferencesFromServer(): Promise<UserNotificationPreferencesExtended> {
const { data, headers } = await getNotificationsPreferences({});
return {
...data,
supportedChannels: headers.get("supported-channels")?.split(",") ?? [],
};
}

type UpdateUserNotificationPreferencesRequest = components["schemas"]["UpdateUserNotificationPreferencesRequest"];
Expand Down
2 changes: 1 addition & 1 deletion client/src/api/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type NewSharedItemNotificationContentItemType =

type UserNotificationUpdateRequest = components["schemas"]["UserNotificationUpdateRequest"];

type NotificationCreateRequest = components["schemas"]["NotificationCreateRequest"];
export type NotificationCreateRequest = components["schemas"]["NotificationCreateRequest"];

type NotificationResponse = components["schemas"]["NotificationResponse"];

Expand Down
31 changes: 20 additions & 11 deletions client/src/api/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,9 @@ export interface paths {
/**
* Returns the current user's preferences for notifications.
* @description Anonymous users cannot have notification preferences. They will receive only broadcasted notifications.
*
* - The settings will contain all possible channels, but the client should only show the ones that are really supported by the server.
* The supported channels are returned in the `supported-channels` header.
*/
get: operations["get_notification_preferences_api_notifications_preferences_get"];
/**
Expand Down Expand Up @@ -9564,6 +9567,7 @@ export interface components {
* Channels
* @description The channels that the user wants to receive notifications from for this category.
* @default {
* "email": true,
* "push": true
* }
*/
Expand All @@ -9580,6 +9584,12 @@ export interface components {
* @description The settings for each channel of a notification category.
*/
NotificationChannelSettings: {
/**
* Email
* @description Whether the user wants to receive email notifications for this category. This setting will be ignored unless the server supports asynchronous tasks.
* @default true
*/
email?: boolean;
/**
* Push
* @description Whether the user wants to receive push notifications in the browser for this category.
Expand Down Expand Up @@ -9628,10 +9638,7 @@ export interface components {
*/
variant: components["schemas"]["NotificationVariant"];
};
/**
* NotificationCreateRequest
* @description Contains the recipients and the notification to create.
*/
/** NotificationCreateRequest */
NotificationCreateRequest: {
/**
* Notification
Expand All @@ -9642,7 +9649,7 @@ export interface components {
* Recipients
* @description The recipients of the notification. Can be a combination of users, groups and roles.
*/
recipients: components["schemas"]["NotificationRecipients"];
recipients: components["schemas"]["NotificationRecipientsRequest"];
};
/** NotificationCreatedResponse */
NotificationCreatedResponse: {
Expand All @@ -9657,11 +9664,8 @@ export interface components {
*/
total_notifications_sent: number;
};
/**
* NotificationRecipients
* @description The recipients of a notification. Can be a combination of users, groups and roles.
*/
NotificationRecipients: {
/** NotificationRecipientsRequest */
NotificationRecipientsRequest: {
/**
* Group IDs
* @description The list of encoded group IDs of the groups that should receive the notification.
Expand Down Expand Up @@ -19983,7 +19987,9 @@ export interface operations {
/** @description Successful Response */
200: {
content: {
"application/json": components["schemas"]["NotificationCreatedResponse"];
"application/json":
| components["schemas"]["NotificationCreatedResponse"]
| components["schemas"]["AsyncTaskResultSummary"];
};
};
/** @description Validation Error */
Expand Down Expand Up @@ -20155,6 +20161,9 @@ export interface operations {
/**
* Returns the current user's preferences for notifications.
* @description Anonymous users cannot have notification preferences. They will receive only broadcasted notifications.
*
* - The settings will contain all possible channels, but the client should only show the ones that are really supported by the server.
* The supported channels are returned in the `supported-channels` header.
*/
parameters?: {
/** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */
Expand Down
10 changes: 9 additions & 1 deletion client/src/components/Notifications/NotificationsList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@ library.add(faCog, faHourglassHalf, faRetweet);
const notificationsStore = useNotificationsStore();
const { notifications, loadingNotifications } = storeToRefs(notificationsStore);

interface Props {
shouldOpenPreferences?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
shouldOpenPreferences: false,
});

const showUnread = ref(false);
const showShared = ref(false);
const preferencesOpen = ref(false);
const preferencesOpen = ref(props.shouldOpenPreferences);
const selectedNotificationIds = ref<string[]>([]);

const haveSelected = computed(() => selectedNotificationIds.value.length > 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { computed, ref, watch } from "vue";
import {
getNotificationsPreferencesFromServer,
updateNotificationsPreferencesOnServer,
UserNotificationPreferences,
UserNotificationPreferencesExtended,
} from "@/api/notifications.preferences";
import { useConfig } from "@/composables/config";
import { Toast } from "@/composables/toast";
Expand Down Expand Up @@ -39,14 +39,15 @@ const { config } = useConfig(true);
const loading = ref(false);
const errorMessage = ref<string | null>(null);
const pushNotificationsGranted = ref(pushNotificationsEnabled());
const notificationsPreferences = ref<UserNotificationPreferences["preferences"]>({});
const notificationsPreferences = ref<UserNotificationPreferencesExtended["preferences"]>({});
const supportedChannels = ref<string[]>([]);

const categories = computed(() => Object.keys(notificationsPreferences.value));
const showPreferences = computed(() => {
return !loading.value && config.value.enable_notification_system && notificationsPreferences.value;
});

const categoryDescriptionMap = {
const categoryDescriptionMap: Record<string, string> = {
message: "You will receive notifications when someone sends you a message.",
new_shared_item: "You will receive notifications when someone shares an item with you.",
};
Expand All @@ -55,6 +56,7 @@ async function getNotificationsPreferences() {
loading.value = true;
await getNotificationsPreferencesFromServer()
.then((data) => {
supportedChannels.value = data.supportedChannels;
notificationsPreferences.value = data.preferences;
})
.catch((error: any) => {
Expand Down Expand Up @@ -148,10 +150,7 @@ watch(
switch />
</div>

<div
v-for="channel in Object.keys(notificationsPreferences[category].channels)"
:key="channel"
class="category-channel">
<div v-for="channel in supportedChannels" :key="channel" class="category-channel">
<BFormCheckbox
v-model="notificationsPreferences[category].channels[channel]"
v-localize
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { computed, type Ref, ref } from "vue";
import { useRouter } from "vue-router/composables";

import { getAllGroups } from "@/api/groups";
import { sendNotification } from "@/api/notifications";
import { NotificationCreateRequest, sendNotification } from "@/api/notifications";
import { getAllRoles } from "@/api/roles";
import { type components } from "@/api/schema";
import { getAllUsers } from "@/api/users";
Expand All @@ -25,7 +25,6 @@ library.add(faInfoCircle);

type SelectOption = [string, string];
type NotificationCreateData = components["schemas"]["NotificationCreateData"];
type NotificationCreateRequest = components["schemas"]["NotificationCreateRequest"];

interface MessageNotificationCreateData extends NotificationCreateData {
category: "message";
Expand Down
3 changes: 3 additions & 0 deletions client/src/entry/analysis/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,9 @@ export function getRouter(Galaxy) {
path: "user/notifications",
component: NotificationsList,
redirect: redirectIf(!Galaxy.config.enable_notification_system, "/") || redirectAnon(),
props: (route) => ({
shouldOpenPreferences: Boolean(route.query.preferences),
}),
},
{
path: "user/notifications/preferences",
Expand Down
11 changes: 11 additions & 0 deletions doc/source/admin/galaxy_options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5463,6 +5463,17 @@
:Type: int


~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
``dispatch_notifications_interval``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

:Description:
The interval in seconds between attempts to dispatch notifications
to users (every 10 minutes by default). Runs in a Celery task.
:Default: ``600``
:Type: int


~~~~~~~~~~~~~~~~~~~~~~
``help_forum_api_url``
~~~~~~~~~~~~~~~~~~~~~~
Expand Down
5 changes: 4 additions & 1 deletion lib/galaxy/celery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,10 @@ def schedule_task(task, interval):
beat_schedule: Dict[str, Dict[str, Any]] = {}
schedule_task("prune_history_audit_table", config.history_audit_table_prune_interval)
schedule_task("cleanup_short_term_storage", config.short_term_storage_cleanup_interval)
schedule_task("cleanup_expired_notifications", config.expired_notifications_cleanup_interval)

if config.enable_notification_system:
schedule_task("cleanup_expired_notifications", config.expired_notifications_cleanup_interval)
schedule_task("dispatch_pending_notifications", config.dispatch_notifications_interval)

if config.object_store_cache_monitor_driver in ["auto", "celery"]:
schedule_task("clean_object_store_caches", config.object_store_cache_monitor_interval)
Expand Down
19 changes: 19 additions & 0 deletions lib/galaxy/celery/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from galaxy.objectstore import BaseObjectStore
from galaxy.objectstore.caching import check_caches
from galaxy.queue_worker import GalaxyQueueWorker
from galaxy.schema.notifications import NotificationCreateRequest
from galaxy.schema.tasks import (
ComputeDatasetHashTaskRequest,
GenerateHistoryContentDownload,
Expand Down Expand Up @@ -483,3 +484,21 @@ def cleanup_expired_notifications(notification_manager: NotificationManager):
@galaxy_task(action="prune object store cache directories")
def clean_object_store_caches(object_store: BaseObjectStore):
check_caches(object_store.cache_targets())


@galaxy_task(action="send notifications to all recipients")
def send_notification_to_recipients_async(
request: NotificationCreateRequest, notification_manager: NotificationManager
):
"""Send a notification to a list of users."""
_, notifications_sent = notification_manager.send_notification_to_recipients(request=request)

log.info(f"Successfully sent {notifications_sent} notifications.")


@galaxy_task(action="dispatch pending notifications")
def dispatch_pending_notifications(notification_manager: NotificationManager):
"""Dispatch pending notifications."""
count = notification_manager.dispatch_pending_notifications_via_channels()
if count:
log.info(f"Successfully dispatched {count} notifications.")
4 changes: 4 additions & 0 deletions lib/galaxy/config/sample/galaxy.yml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -2911,6 +2911,10 @@ galaxy:
# a Celery task.
#expired_notifications_cleanup_interval: 86400

# The interval in seconds between attempts to dispatch notifications
# to users (every 10 minutes by default). Runs in a Celery task.
#dispatch_notifications_interval: 600

# The URL pointing to the Galaxy Help Forum API base URL. The API must
# be compatible with Discourse API (https://docs.discourse.org/).
#help_forum_api_url: https://help.galaxyproject.org/
Expand Down
7 changes: 7 additions & 0 deletions lib/galaxy/config/schemas/config_schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3990,6 +3990,13 @@ mapping:
desc: |
The interval in seconds between attempts to delete all expired notifications from the database (every 24 hours by default). Runs in a Celery task.

dispatch_notifications_interval:
type: int
required: false
default: 600
desc: |
The interval in seconds between attempts to dispatch notifications to users (every 10 minutes by default). Runs in a Celery task.

help_forum_api_url:
type: str
required: false
Expand Down
84 changes: 84 additions & 0 deletions lib/galaxy/config/templates/mail/notifications/message-email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
Use this template to customize the HTML-formatted email your users will receive
when a new notification of category "message" is sent to them.
Copy the file to {{ templates_dir }}/mail/notifications/message-email.html and modify as required.

If you are adding URLs, remember that only absolute URLs (with
a domain name) make sense in email! They can be served from any stable
location, including your Galaxy server or GitHub.

The following variables are available for inserting into the HTML with Jinja2
syntax, like {{ variable_name }}. They will be rendered into the text before
the email is sent:

- name The user's name
- user_email The user's email
- date Date and time of the notification
- hostname Your galaxy's hostname (i.e. usegalaxy.* or the value in `server_name` from the galaxy config file)
- contact_email Your galaxy's contact email
- notification_settings_url The URL to the user's notification settings to manage their subscriptions
- content The message payload
- subject The message subject
- content The message content in HTML (converted from Markdown)
- galaxy_url The URL to the Galaxy instance (i.e. https://usegalaxy.*)

Template begins here >>>>>>
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>[Galaxy] New message received: {{ content['subject'] }}</title>
<link href="https://fonts.googleapis.com/css2?family=Roboto&display=swap" rel="stylesheet">
</head>
<body style="font-family: 'Roboto', sans-serif;">

<p style="font-size: 12pt;">
Hello {{ name }},<br><br>

You have received a new message on <b>{{ date }}</b> from the Galaxy Team at <b>{{ hostname }}</b>, here are the details:
<br><br>
</p>

<p style="font-size: 12pt;">
<strong>Subject:</strong>
<br>
{{ content['subject'] }}
<br><br>
<strong>Message:</strong>
<br>
{{ content['message'] }}
<br><br>
</p>

<p style="font-size: 12pt;">
Thank you for using Galaxy!
</p>


<p style="font-size: 12pt;">
Regards,<br>
Your Galaxy Team at <a href="{{ galaxy_url }}">{{ hostname }}</a>
</p>

<p style="font-size: 10pt;">
You received this email because you are subscribed to receive notifications from the Galaxy Team.
{% if notification_settings_url %}
You can manage your notification settings <a href="{{ notification_settings_url }}">here</a>.
{% endif %}

<br>

{% if contact_email %}
This is an automated email. If you have any questions or concerns, please do not reply to this email, instead, contact us at <a href="mailto:{{ contact_email }}">{{ contact_email }}</a>.
{% endif %}
</p>

<img
style="width: 130px; height: auto; margin: 15px 0;"
src="https://galaxyproject.org/images/galaxy-logos/galaxy_project_logo_square.png"
alt="Galaxy project logo"
>

<br>

</body>
</html>
Loading
Loading