Skip to content

Commit

Permalink
Handle channel settings depending on server config
Browse files Browse the repository at this point in the history
External notification channels (like email) are processed in Celery tasks. When Celery is not enabled in the server, we cannot show those channels to the user and we have to indicate in the API that those will be ignored.
  • Loading branch information
davelopez committed Apr 8, 2024
1 parent 1250ed5 commit 8479189
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 39 deletions.
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
6 changes: 6 additions & 0 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 @@ -20160,6 +20163,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
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
66 changes: 41 additions & 25 deletions lib/galaxy/managers/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,21 @@ class CleanupResultSummary(NamedTuple):
deleted_associations_count: int


class NotificationRecipientResolverStrategy(Protocol):
def resolve_users(self, recipients: NotificationRecipients) -> List[User]:
pass


class NotificationChannelPlugin(Protocol):
config: GalaxyAppConfiguration

def __init__(self, config: GalaxyAppConfiguration):
self.config = config

def send(self, notification: Notification, user: User):
raise NotImplementedError


class NotificationManager:
"""Manager class to interact with the database models related with Notifications."""

Expand Down Expand Up @@ -113,11 +128,7 @@ def __init__(self, sa_session: galaxy_scoped_session, config: GalaxyAppConfigura
Notification.expiration_time,
Notification.content,
]
# Register the supported notification channels here
self.channel_plugins: Dict[str, NotificationChannelPlugin] = {
"push": NoOpNotificationChannelPlugin(self.config), # Push notifications are handled by the client
"email": EmailNotificationChannelPlugin(self.config),
}
self.channel_plugins = self._register_supported_channels()

@property
def notifications_enabled(self):
Expand Down Expand Up @@ -407,14 +418,30 @@ def update_user_notification_preferences(
self, user: User, request: UpdateUserNotificationPreferencesRequest
) -> UserNotificationPreferences:
"""Updates the user's notification preferences with the requested changes."""
notification_preferences = self.get_user_notification_preferences(user)
notification_preferences.update(request.preferences)
user.preferences[NOTIFICATION_PREFERENCES_SECTION_NAME] = (
notification_preferences.model_dump_json()
) # type:ignore[index]
preferences = self.get_user_notification_preferences(user)
preferences.update(request.preferences)
user.preferences[NOTIFICATION_PREFERENCES_SECTION_NAME] = preferences.model_dump_json() # type:ignore[index]
with transaction(self.sa_session):
self.sa_session.commit()
return notification_preferences
return preferences

def _register_supported_channels(self) -> Dict[str, NotificationChannelPlugin]:
"""Registers the supported notification channels in this server."""
supported_channels: Dict[str, NotificationChannelPlugin] = {
# Push notifications are handled client-side so no real plugin is needed
"push": NoOpNotificationChannelPlugin(self.config),
}

if self.can_send_notifications_async:
# Most additional channels require asynchronous processing and will be
# handled by Celery tasks. Add their plugins here.
supported_channels["email"] = EmailNotificationChannelPlugin(self.config)

return supported_channels

def get_supported_channels(self) -> Set[str]:
"""Returns the set of supported notification channels in this server."""
return set(self.channel_plugins.keys())

def cleanup_expired_notifications(self) -> CleanupResultSummary:
"""
Expand Down Expand Up @@ -492,9 +519,8 @@ def _broadcasted_notifications_query(self, since: Optional[datetime] = None, act
return stmt


class NotificationRecipientResolverStrategy(Protocol):
def resolve_users(self, recipients: NotificationRecipients) -> List[User]:
pass
# --------------------------------------
# Notification Recipients Resolver Implementations


class NotificationRecipientResolver:
Expand Down Expand Up @@ -603,17 +629,7 @@ def resolve_users(self, recipients: NotificationRecipients) -> List[User]:


# --------------------------------------
# Notification Channel Plugins


class NotificationChannelPlugin(Protocol):
config: GalaxyAppConfiguration

def __init__(self, config: GalaxyAppConfiguration):
self.config = config

def send(self, notification: Notification, user: User):
raise NotImplementedError
# Notification Channel Plugins Implementations


class NoOpNotificationChannelPlugin(NotificationChannelPlugin):
Expand Down
5 changes: 4 additions & 1 deletion lib/galaxy/schema/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,10 @@ class NotificationChannelSettings(Model):
email: bool = Field(
default=True,
title="Email",
description="Whether the user wants to receive email notifications for this category.",
description=(
"Whether the user wants to receive email notifications for this category. "
"This setting will be ignored unless the server supports asynchronous tasks."
),
)
# TODO: Add more channels here and implement the corresponding plugin in lib/galaxy/managers/notification.py
# matrix: bool # Possible future Matrix.org integration?
Expand Down
14 changes: 12 additions & 2 deletions lib/galaxy/webapps/galaxy/api/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,20 @@ def get_notifications_status(
)
def get_notification_preferences(
self,
response: Response,
trans: ProvidesUserContext = DependsOnTrans,
) -> UserNotificationPreferences:
"""Anonymous users cannot have notification preferences. They will receive only broadcasted notifications."""
return self.service.get_user_notification_preferences(trans)
"""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.
"""
result = self.service.get_user_notification_preferences(trans)
# Inform the client which channels are really supported by the server since the settings will contain all possible channels.
response.headers["supported-channels"] = str.join(
",", self.service.notification_manager.get_supported_channels()
)
return result

@router.put(
"/api/notifications/preferences",
Expand Down

0 comments on commit 8479189

Please sign in to comment.