+
+ You have received a new message on {{ date }} from the Galaxy Team at {{ hostname }}, here are the details:
+
+
+
+
+ Subject:
+
+ {{ content['subject'] }}
+
+ Message:
+
+ {{ content['message'] }}
+
+
+
+
+ Thank you for using Galaxy!
+
+
+
+
+ Regards,
+ Your Galaxy Team at {{ hostname }}
+
+
+
+ This is an automated email. If you have any questions or concerns, please do not reply to this email, instead, contact us at {{ contact_email }}.
+
+ You received this email because you are subscribed to receive notifications of this type.
+ You can manage your notification settings here.
+
+
+
+
+
+
+
+
diff --git a/lib/galaxy/config/templates/mail/notifications/message-email.txt b/lib/galaxy/config/templates/mail/notifications/message-email.txt
new file mode 100644
index 00000000000..57ddb0de4c7
--- /dev/null
+++ b/lib/galaxy/config/templates/mail/notifications/message-email.txt
@@ -0,0 +1,41 @@
+Use this template to customize the text 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.txt and modify as required.
+
+If you are adding URLs into this, 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 text 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
+- 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)
+
+Template begins here>>>>>>
+Hello {{ name }},
+
+You have received a new message on {{ date }} from the Galaxy Team at {{ hostname }}, here are the details:
+
+Subject:
+{{ content['subject'] }}
+
+Message:
+{{ content['message'] }}
+
+To manage your notification settings, please visit {{ notification_settings_url }}.
+
+If you have any questions, please contact us at {{ contact_email }}.
+
+Thank you for using Galaxy!
+
+Regards,
+Your Galaxy Team
From a6ba1a8cbbcf20dcac2982c95e87bf48ebc3c9de Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Wed, 3 Apr 2024 18:08:50 +0200
Subject: [PATCH 05/31] Add email templates for shared item notifications
---
.../notifications/new_shared_item-email.html | 74 +++++++++++++++++++
.../notifications/new_shared_item-email.txt | 41 ++++++++++
2 files changed, 115 insertions(+)
create mode 100644 lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
create mode 100644 lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt
diff --git a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
new file mode 100644
index 00000000000..45a610bbb38
--- /dev/null
+++ b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
@@ -0,0 +1,74 @@
+Use this template to customize the HTML email your users will receive
+when a new notification of category "new_shared_item" is sent to them.
+Copy the file to {{ templates_dir }}/mail/notifications/new_shared_item-email.html and modify as required.
+
+If you are adding URLs into this, 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
+- contact_email Your galaxy's contact email
+- notification_settings_url The URL to the user's notification settings to manage their subscriptions
+- content The new_shared_item payload
+ - item_type The type of the shared item
+ - item_name The name of the shared item
+ - owner_name The name of the owner of the shared item
+ - slug The slug of the shared item. Used for the link to the item.
+
+Template begins here >>>>>>
+
+
+
+
+ [Galaxy] New {{ content['item_type'] }} shared with you:
+
+
+
+
+
+ Hello {{ name }},
+
+ A new {{ content['item_type'] }} named {{ content['item_name'] }} has been shared with you on {{ date }} by {{ content['owner_name'] }}.
+
+
+
+ To access the shared {{ content['item_type'] }}, please visit the following link:
+
+ {{ content['item_name'] }}
+
+
+
+ Thank you for using Galaxy!
+
+
+
+
+ Regards,
+ Your Galaxy Team at {{ hostname }}
+
+
+
+ This is an automated email. If you have any questions or concerns, please do not reply to this email, instead, contact us at {{ contact_email }}.
+
+ You received this email because you are subscribed to receive notifications when another user shares an item with you.
+
+ You can manage your notification settings here.
+
+
+
+
+
+
+
+
diff --git a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt
new file mode 100644
index 00000000000..b4236fa9fab
--- /dev/null
+++ b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt
@@ -0,0 +1,41 @@
+Use this template to customize the text email your users will receive
+when a new notification of category "new_shared_item" is sent to them.
+Copy the file to {{ templates_dir }}/mail/notifications/new_shared_item-email.txt and modify as required.
+
+If you are adding URLs into this, 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 text 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
+- contact_email Your galaxy's contact email
+- notification_settings_url The URL to the user's notification settings to manage their subscriptions
+- content The new_shared_item payload
+ - item_type The type of the shared item
+ - item_name The name of the shared item
+ - owner_name The name of the owner of the shared item
+ - slug The slug of the shared item. Used for the link to the item.
+
+Template begins here>>>>>>
+Hello {{ name }},
+
+A new {{ content['item_type'] }} has been shared with you by {{ content['owner_name'] }}.
+
+To access the shared {{ content['item_type'] }}, please visit the following link:
+{{ hostname }}/{{ content['slug'] }}
+
+You received this email because you are subscribed to receive notifications when another user shares an item with you.
+To manage your notification settings, please visit {{ notification_settings_url }}.
+
+If you have any questions, please contact us at {{ contact_email }}.
+
+Thank you for using Galaxy!
+
+Regards,
+Your Galaxy Team
From 81ae5f4cbc021a0ee37d6aba8d6a86b94e3089ca Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Wed, 3 Apr 2024 18:24:01 +0200
Subject: [PATCH 06/31] Add EmailNotificationChannelPlugin and basic email
sending
---
lib/galaxy/managers/notification.py | 198 +++++++++++++++++++++++++++-
lib/galaxy/schema/notifications.py | 8 +-
2 files changed, 197 insertions(+), 9 deletions(-)
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index 6b6cceab4fe..6374b4dadf3 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -1,14 +1,24 @@
import logging
-from datetime import datetime
+from datetime import (
+ datetime,
+ UTC,
+)
+from enum import Enum
from typing import (
+ cast,
+ Dict,
List,
NamedTuple,
Optional,
Set,
Tuple,
+ Type,
)
-from pydantic import ValidationError
+from pydantic import (
+ BaseModel,
+ ValidationError,
+)
from sqlalchemy import (
and_,
delete,
@@ -23,11 +33,15 @@ from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.sql import Select
from typing_extensions import Protocol
-from galaxy.config import GalaxyAppConfiguration
+from galaxy.config import (
+ GalaxyAppConfiguration,
+ templates,
+)
from galaxy.exceptions import (
ConfigDoesNotAllowException,
ObjectNotFound,
)
+from galaxy.managers.markdown_util import to_html
from galaxy.model import (
GroupRoleAssociation,
Notification,
@@ -39,11 +53,15 @@ from galaxy.model import (
from galaxy.model.base import transaction
from galaxy.model.scoped_session import galaxy_scoped_session
from galaxy.schema.notifications import (
+ AnyNotificationContent,
BroadcastNotificationCreateRequest,
GenericNotificationCreateRequest,
MandatoryNotificationCategory,
+ MessageNotificationContent,
+ NewSharedItemNotificationContent,
NotificationBroadcastUpdateRequest,
NotificationCategorySettings,
+ NotificationChannelSettings,
NotificationCreateData,
NotificationRecipients,
PersonalNotificationCategory,
@@ -51,7 +69,10 @@ from galaxy.schema.notifications import (
UserNotificationPreferences,
UserNotificationUpdateRequest,
)
-from galaxy.util import unicodify
+from galaxy.util import (
+ send_mail,
+ unicodify,
+)
log = logging.getLogger(__name__)
@@ -95,6 +116,11 @@ class NotificationManager:
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),
+ }
@property
def notifications_enabled(self):
@@ -102,7 +128,7 @@ class NotificationManager:
@property
def _now(self):
- return datetime.utcnow()
+ return datetime.now(UTC)
@property
def _notification_is_active(self):
@@ -133,6 +159,8 @@ class NotificationManager:
recipient_users = self.recipient_resolver.resolve(request.recipients)
notification = self._create_notification_model(request.notification)
self.sa_session.add(notification)
+ with transaction(self.sa_session):
+ self.sa_session.commit()
notifications_sent = self._send_notifications(notification, recipient_users)
with transaction(self.sa_session):
@@ -154,9 +182,22 @@ class NotificationManager:
def _send_notification_to_user(self, notification: Notification, user: User):
category_settings = self._get_user_category_settings(user, notification.category)
if self._is_subscribed_to_category(category_settings):
- # Send the in-app notification
+ # Send the in-app notification always
user_notification_association = UserNotificationAssociation(user, notification)
self.sa_session.add(user_notification_association)
+ # Send the notification via any other configured channels
+ self._send_via_channels(notification, user, category_settings.channels)
+
+ def _send_via_channels(self, notification: Notification, user: User, channel_settings: NotificationChannelSettings):
+ channels = channel_settings.model_fields_set
+ for channel in channels:
+ if channel not in self.channel_plugins:
+ log.warning(f"Notification channel '{channel}' is not supported.")
+ continue
+ if getattr(channel_settings, channel, False) is False:
+ continue # User opted out of this channel
+ plugin = self.channel_plugins[channel]
+ plugin.send(notification, user)
def _is_subscribed_to_category(self, category_settings: NotificationCategorySettings) -> bool:
return category_settings.enabled
@@ -312,7 +353,7 @@ class NotificationManager:
else None
)
try:
- return UserNotificationPreferences.parse_raw(current_notification_preferences)
+ return UserNotificationPreferences.model_validate_json(current_notification_preferences)
except ValidationError:
# Gracefully return default preferences is they don't exist or get corrupted
return UserNotificationPreferences.default()
@@ -512,3 +553,146 @@ class RecursiveCTEStrategy(NotificationRecipientResolverStrategy):
def resolve_users(self, recipients: NotificationRecipients) -> List[User]:
# TODO Implement resolver using recursive CTEs?
return []
+
+
+# --------------------------------------
+# 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
+
+
+class NoOpNotificationChannelPlugin(NotificationChannelPlugin):
+ def send(self, notification: Notification, user: User):
+ pass
+
+
+class TemplateFormats(str, Enum):
+ HTML = "html"
+ TXT = "txt"
+
+
+class NotificationContext(BaseModel):
+ """Information passed to the email template to render the body."""
+
+ name: str
+ user_email: str
+ date: str
+ hostname: str
+ contact_email: str
+ notification_settings_url: str
+ content: AnyNotificationContent
+
+
+class EmailNotificationTemplateBuilder(Protocol):
+ config: GalaxyAppConfiguration
+ notification: Notification
+ user: User
+
+ def __init__(self, config: GalaxyAppConfiguration, notification: Notification, user: User):
+ self.config = config
+ self.notification = notification
+ self.user = user
+
+ def get_content(self, template_format: TemplateFormats) -> AnyNotificationContent:
+ """Processes the notification content to be rendered in the email body.
+
+ This should be implemented by each concrete template builder.
+ """
+ raise NotImplementedError
+
+ def get_subject(self) -> str:
+ """Returns the subject of the email to be sent.
+
+ This should be implemented by each concrete template builder.
+ """
+ raise NotImplementedError
+
+ def build_context(self, template_format: TemplateFormats) -> NotificationContext:
+ notification = self.notification
+ user = self.user
+ notification_date = notification.publication_time if notification.publication_time else notification.create_time
+ return NotificationContext(
+ name=user.username,
+ user_email=user.email,
+ date=notification_date.strftime("%B %d, %Y"),
+ hostname=self.config.server_name,
+ contact_email=self.config.error_email_to or "",
+ # TODO: How to build the proper URL without access to trans?
+ notification_settings_url=f"https://{self.config.server_name}/user/notifications",
+ content=self.get_content(template_format),
+ )
+
+ def get_body(self, template_format: TemplateFormats) -> str:
+ template_path = f"mail/notifications/{self.notification.category}-email.{template_format.value}"
+ context = self.build_context(template_format)
+ return templates.render(
+ template_path,
+ context.model_dump(),
+ self.config.templates_dir,
+ )
+
+
+class MessageEmailNotificationTemplateBuilder(EmailNotificationTemplateBuilder):
+
+ markdown_to = {
+ TemplateFormats.HTML: to_html,
+ TemplateFormats.TXT: lambda x: x, # TODO: strip markdown?
+ }
+
+ def get_content(self, template_format: TemplateFormats) -> AnyNotificationContent:
+ content = MessageNotificationContent.model_construct(**self.notification.content)
+ content.message = self.markdown_to[template_format](content.message)
+ return content
+
+ def get_subject(self) -> str:
+ return "[Galaxy] New message received"
+
+
+class NewSharedItemEmailNotificationTemplateBuilder(EmailNotificationTemplateBuilder):
+
+ def get_content(self, template_format: TemplateFormats) -> AnyNotificationContent:
+ content = NewSharedItemNotificationContent.model_construct(**self.notification.content)
+ return content
+
+ def get_subject(self) -> str:
+ content = cast(NewSharedItemNotificationContent, self.get_content(TemplateFormats.TXT))
+ return f"[Galaxy] New {content.item_type} shared with you"
+
+
+class EmailNotificationChannelPlugin(NotificationChannelPlugin):
+
+ # Register the supported email templates here
+ email_templates_by_category: Dict[PersonalNotificationCategory, Type[EmailNotificationTemplateBuilder]] = {
+ PersonalNotificationCategory.message: MessageEmailNotificationTemplateBuilder,
+ PersonalNotificationCategory.new_shared_item: NewSharedItemEmailNotificationTemplateBuilder,
+ }
+
+ def send(self, notification: Notification, user: User):
+ try:
+ email_template_builder = self.email_templates_by_category.get(notification.category)
+ if email_template_builder is None:
+ log.warning(f"No email template found for notification category '{notification.category}'.")
+ return
+ template_builder = email_template_builder(self.config, notification, user)
+ subject = template_builder.get_subject()
+ text_body = template_builder.get_body(TemplateFormats.TXT)
+ html_body = template_builder.get_body(TemplateFormats.HTML)
+ send_mail(
+ frm=self.config.email_from,
+ to=user.email,
+ subject=subject,
+ body=text_body,
+ config=self.config,
+ html=html_body,
+ )
+ except Exception as e:
+ log.error(f"Error sending email notification to user {user.id}. Reason: {unicodify(e)}")
+ pass
diff --git a/lib/galaxy/schema/notifications.py b/lib/galaxy/schema/notifications.py
index f78624e1120..bbf19a9bc39 100644
--- a/lib/galaxy/schema/notifications.py
+++ b/lib/galaxy/schema/notifications.py
@@ -418,8 +418,12 @@ class NotificationChannelSettings(Model):
title="Push",
description="Whether the user wants to receive push notifications in the browser for this category.",
)
- # TODO: Add more channels
- # email: bool # Not supported for now
+ email: bool = Field(
+ default=True,
+ title="Email",
+ description="Whether the user wants to receive email notifications for this category.",
+ )
+ # TODO: Add more channels here and implement the corresponding plugin in lib/galaxy/managers/notification.py
# matrix: bool # Possible future Matrix.org integration?
From fd7bd1b6dae34aa5a82473e4e358fb24b68ac8df Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 4 Apr 2024 16:33:11 +0200
Subject: [PATCH 07/31] Add db revision for notifications dispatched column
---
..._add_dispatched_column_to_notifications.py | 55 +++++++++++++++++++
1 file changed, 55 insertions(+)
create mode 100644 lib/galaxy/model/migrations/alembic/versions_gxy/303a5583a030_add_dispatched_column_to_notifications.py
diff --git a/lib/galaxy/model/migrations/alembic/versions_gxy/303a5583a030_add_dispatched_column_to_notifications.py b/lib/galaxy/model/migrations/alembic/versions_gxy/303a5583a030_add_dispatched_column_to_notifications.py
new file mode 100644
index 00000000000..d436ffff531
--- /dev/null
+++ b/lib/galaxy/model/migrations/alembic/versions_gxy/303a5583a030_add_dispatched_column_to_notifications.py
@@ -0,0 +1,55 @@
+"""add dispatched column to notifications
+
+Revision ID: 303a5583a030
+Revises: 55f02fd8ab6c
+Create Date: 2024-04-04 11:45:54.018829
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import (
+ Boolean,
+ Column,
+)
+
+from galaxy.model.database_object_names import build_index_name
+from galaxy.model.migrations.util import (
+ add_column,
+ drop_column,
+ drop_index,
+ transaction,
+)
+
+# revision identifiers, used by Alembic.
+revision = "303a5583a030"
+down_revision = "55f02fd8ab6c"
+branch_labels = None
+depends_on = None
+
+# database object names used in this revision
+table_name = "notification"
+column_name = "dispatched"
+publication_time_column_name = "publication_time"
+index_name = build_index_name(table_name, column_name)
+
+
+def upgrade():
+ with transaction():
+ add_column(
+ table_name,
+ Column(column_name, Boolean(), index=True, nullable=False, default=False, server_default=sa.false()),
+ )
+ # Set as already dispatched any notifications older than the current time to avoid sending them again
+ table = sa.sql.table(
+ table_name,
+ Column(column_name, Boolean()),
+ Column(publication_time_column_name, sa.DateTime()),
+ )
+ op.execute(table.update().where(table.c.publication_time <= sa.func.now()).values(dispatched=True))
+
+
+def downgrade():
+ with transaction():
+ drop_index(index_name, table_name)
+ drop_column(table_name, column_name)
From 3fe77fb2a95afb7ba8e964b190a4e0ce8a4a8af1 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 4 Apr 2024 16:33:56 +0200
Subject: [PATCH 08/31] Add dispatched column to notification model
---
lib/galaxy/model/__init__.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py
index ae7da2d3a06..2b1d2d6f0de 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -2944,6 +2944,9 @@ class Notification(Base, Dictifiable, RepresentById):
variant: Mapped[Optional[str]] = mapped_column(
String(16), index=True
) # Defines the 'importance' of the notification ('info', 'warning', 'urgent', etc.). Used for filtering, highlight rendering, etc
+ dispatched: Mapped[Boolean] = mapped_column(
+ Boolean, index=True, nullable=False, default=False
+ ) # Whether the notification has been dispatched to users via other channels
# A bug in early 23.1 led to values being stored as json string, so we use this special type to process the result value twice.
# content should always be a dict
content: Mapped[Optional[bytes]] = mapped_column(DoubleEncodedJsonType)
From dda46db7d3eab4e3804ca3df389efd71050e5cdc Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 4 Apr 2024 18:30:06 +0200
Subject: [PATCH 09/31] Adapt notification model mapped types
Unfortunately I thought the default for columns was "non-nullable" so these columns are nullable in the database, but they will never be in the app.
This change matches the type expected in the app with the real nullable state in the database.
---
lib/galaxy/model/__init__.py | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py
index 2b1d2d6f0de..25e659f5de2 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -2937,12 +2937,14 @@ class Notification(Base, Dictifiable, RepresentById):
expiration_time: Mapped[Optional[datetime]] = mapped_column(
default=now() + timedelta(days=30 * 6)
) # The expiration date, expired notifications will be permanently removed from DB regularly
- source: Mapped[Optional[str]] = mapped_column(String(32), index=True) # Who (or what) generated the notification
- category: Mapped[Optional[str]] = mapped_column(
- String(64), index=True
+ source: Mapped[str] = mapped_column(
+ String(32), index=True, nullable=True
+ ) # Who (or what) generated the notification
+ category: Mapped[str] = mapped_column(
+ String(64), index=True, nullable=True
) # Category of the notification, defines its contents. Used for filtering, un/subscribing, etc
- variant: Mapped[Optional[str]] = mapped_column(
- String(16), index=True
+ variant: Mapped[str] = mapped_column(
+ String(16), index=True, nullable=True
) # Defines the 'importance' of the notification ('info', 'warning', 'urgent', etc.). Used for filtering, highlight rendering, etc
dispatched: Mapped[Boolean] = mapped_column(
Boolean, index=True, nullable=False, default=False
From 1ec4d428a040ba6d3881285657777488a3e4754f Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 4 Apr 2024 18:36:17 +0200
Subject: [PATCH 10/31] Ignore some inaccurate types from model mappings
---
lib/galaxy/managers/notification.py | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index 6374b4dadf3..129d974b870 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -648,7 +648,7 @@ class MessageEmailNotificationTemplateBuilder(EmailNotificationTemplateBuilder):
}
def get_content(self, template_format: TemplateFormats) -> AnyNotificationContent:
- content = MessageNotificationContent.model_construct(**self.notification.content)
+ content = MessageNotificationContent.model_construct(**self.notification.content) # type:ignore[arg-type]
content.message = self.markdown_to[template_format](content.message)
return content
@@ -659,7 +659,7 @@ class MessageEmailNotificationTemplateBuilder(EmailNotificationTemplateBuilder):
class NewSharedItemEmailNotificationTemplateBuilder(EmailNotificationTemplateBuilder):
def get_content(self, template_format: TemplateFormats) -> AnyNotificationContent:
- content = NewSharedItemNotificationContent.model_construct(**self.notification.content)
+ content = NewSharedItemNotificationContent.model_construct(**self.notification.content) # type:ignore[arg-type]
return content
def get_subject(self) -> str:
@@ -677,7 +677,8 @@ class EmailNotificationChannelPlugin(NotificationChannelPlugin):
def send(self, notification: Notification, user: User):
try:
- email_template_builder = self.email_templates_by_category.get(notification.category)
+ category = cast(PersonalNotificationCategory, notification.category)
+ email_template_builder = self.email_templates_by_category.get(category)
if email_template_builder is None:
log.warning(f"No email template found for notification category '{notification.category}'.")
return
From 8a62db2b29dd590f482b916ff8b4a7c7a412f1da Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 4 Apr 2024 18:39:36 +0200
Subject: [PATCH 11/31] Refactor notifications manager to send via other
channels
This will still create the notifications and associations in the database on request, but will defer sending emails (or any other channel) to a different function called `dispatch_pending_notifications_via_channels`.
---
lib/galaxy/managers/notification.py | 74 ++++++++++++++++++++++++-----
1 file changed, 61 insertions(+), 13 deletions(-)
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index 129d974b870..a3146158eae 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -152,8 +152,8 @@ class NotificationManager:
"""
Creates a new notification and associates it with all the recipient users.
- It takes into account the user's notification preferences to decide if the notification should be sent to them,
- and also uses the configured channels to send the notification via different means (e.g. email, etc).
+ It takes into account the user's notification preferences to decide if the notification should be sent to them.
+ No other notification channel is used here, only the internal database associations are created.
"""
self.ensure_notifications_enabled()
recipient_users = self.recipient_resolver.resolve(request.recipients)
@@ -162,32 +162,80 @@ class NotificationManager:
with transaction(self.sa_session):
self.sa_session.commit()
- notifications_sent = self._send_notifications(notification, recipient_users)
+ notifications_sent = self._create_associations(notification, recipient_users)
with transaction(self.sa_session):
self.sa_session.commit()
return notification, notifications_sent
- def _send_notifications(self, notification: Notification, users: List[User]) -> int:
+ def _create_associations(self, notification: Notification, users: List[User]) -> int:
success_count = 0
for user in users:
try:
- self._send_notification_to_user(notification, user)
- success_count += 1
+ if self._is_user_subscribed_to_category(user, notification.category): # type:ignore[arg-type]
+ user_notification_association = UserNotificationAssociation(user, notification)
+ self.sa_session.add(user_notification_association)
+ success_count += 1
except Exception as e:
log.error(f"Error sending notification to user {user.id}. Reason: {unicodify(e)}")
continue
return success_count
- def _send_notification_to_user(self, notification: Notification, user: User):
- category_settings = self._get_user_category_settings(user, notification.category)
- if self._is_subscribed_to_category(category_settings):
- # Send the in-app notification always
- user_notification_association = UserNotificationAssociation(user, notification)
- self.sa_session.add(user_notification_association)
- # Send the notification via any other configured channels
+ def dispatch_pending_notifications_via_channels(self) -> int:
+ """
+ Dispatches all pending notifications to the users depending on the configured channels.
+
+ This is meant to be called periodically by a background task.
+ """
+ self.ensure_notifications_enabled()
+ pending_notifications = self.get_pending_notifications()
+
+ # Mark all pending notifications as dispatched
+ for notification in pending_notifications:
+ notification.dispatched = True
+
+ with transaction(self.sa_session):
+ self.sa_session.commit()
+
+ # Do the actual dispatching
+ for notification in pending_notifications:
+ self._dispatch_notification_to_users(notification)
+
+ return len(pending_notifications)
+
+ def get_pending_notifications(self):
+ """
+ Returns all pending notifications that have not been dispatched yet
+ but are due and ready to be sent to the users.
+ """
+ stmt = select(Notification).where(Notification.dispatched == false(), self._notification_is_active)
+ return self.sa_session.execute(stmt).scalars().all()
+
+ def _dispatch_notification_to_users(self, notification: Notification):
+ users = self._get_associated_users(notification)
+ for user in users:
+ category_settings = self._get_user_category_settings(user, notification.category) # type:ignore[arg-type]
+ if not self._is_subscribed_to_category(category_settings):
+ continue
self._send_via_channels(notification, user, category_settings.channels)
+ def _get_associated_users(self, notification: Notification):
+ stmt = (
+ select(User)
+ .join(
+ UserNotificationAssociation,
+ UserNotificationAssociation.user_id == User.id,
+ )
+ .where(
+ UserNotificationAssociation.notification_id == notification.id,
+ )
+ )
+ return self.sa_session.execute(stmt).scalars().all()
+
+ def _is_user_subscribed_to_category(self, user: User, category: PersonalNotificationCategory) -> bool:
+ category_settings = self._get_user_category_settings(user, category)
+ return self._is_subscribed_to_category(category_settings)
+
def _send_via_channels(self, notification: Notification, user: User, channel_settings: NotificationChannelSettings):
channels = channel_settings.model_fields_set
for channel in channels:
From a4e22b4f417efe1e100baed0b3603ecb4155367e Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 4 Apr 2024 18:40:11 +0200
Subject: [PATCH 12/31] Add celery task to dispatch pending notifications
---
lib/galaxy/celery/tasks.py | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/lib/galaxy/celery/tasks.py b/lib/galaxy/celery/tasks.py
index 3478f7d2e94..acc65a593c0 100644
--- a/lib/galaxy/celery/tasks.py
+++ b/lib/galaxy/celery/tasks.py
@@ -494,3 +494,11 @@ def send_notification_to_recipients_async(
_, 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.")
From dbdd5bca818f339395f418366120e87d118703ab Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Fri, 5 Apr 2024 11:49:25 +0200
Subject: [PATCH 13/31] Add dispatch_notifications_interval config option
---
doc/source/admin/galaxy_options.rst | 11 +++++++++++
lib/galaxy/config/sample/galaxy.yml.sample | 4 ++++
lib/galaxy/config/schemas/config_schema.yml | 7 +++++++
3 files changed, 22 insertions(+)
diff --git a/doc/source/admin/galaxy_options.rst b/doc/source/admin/galaxy_options.rst
index b5511036af2..81990b430c5 100644
--- a/doc/source/admin/galaxy_options.rst
+++ b/doc/source/admin/galaxy_options.rst
@@ -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``
~~~~~~~~~~~~~~~~~~~~~~
diff --git a/lib/galaxy/config/sample/galaxy.yml.sample b/lib/galaxy/config/sample/galaxy.yml.sample
index 8032d879039..183d7b93784 100644
--- a/lib/galaxy/config/sample/galaxy.yml.sample
+++ b/lib/galaxy/config/sample/galaxy.yml.sample
@@ -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/
diff --git a/lib/galaxy/config/schemas/config_schema.yml b/lib/galaxy/config/schemas/config_schema.yml
index 99dbe965774..4ebf9f7adfa 100644
--- a/lib/galaxy/config/schemas/config_schema.yml
+++ b/lib/galaxy/config/schemas/config_schema.yml
@@ -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
From 9c2d569774c26eab466f6746c779d8cc00eb95bd Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Fri, 5 Apr 2024 12:02:10 +0200
Subject: [PATCH 14/31] Add dispatch_pending_notifications to periodic tasks
---
lib/galaxy/celery/__init__.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/lib/galaxy/celery/__init__.py b/lib/galaxy/celery/__init__.py
index 73d9a509953..3d0cb4365c3 100644
--- a/lib/galaxy/celery/__init__.py
+++ b/lib/galaxy/celery/__init__.py
@@ -238,7 +238,9 @@ def setup_periodic_tasks(config, celery_app):
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)
+ 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)
From bb6f68852fc0265d707c7dedc7a37fa536790b64 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Fri, 5 Apr 2024 12:14:56 +0200
Subject: [PATCH 15/31] Schedule periodic notifications tasks only when enabled
---
lib/galaxy/celery/__init__.py | 5 +++--
test/unit/app/test_celery.py | 4 ----
2 files changed, 3 insertions(+), 6 deletions(-)
diff --git a/lib/galaxy/celery/__init__.py b/lib/galaxy/celery/__init__.py
index 3d0cb4365c3..11432481529 100644
--- a/lib/galaxy/celery/__init__.py
+++ b/lib/galaxy/celery/__init__.py
@@ -239,8 +239,9 @@ def setup_periodic_tasks(config, celery_app):
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)
- schedule_task("dispatch_pending_notifications", config.dispatch_notifications_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)
diff --git a/test/unit/app/test_celery.py b/test/unit/app/test_celery.py
index 30dc37a3b0b..db16b23b4b8 100644
--- a/test/unit/app/test_celery.py
+++ b/test/unit/app/test_celery.py
@@ -26,10 +26,6 @@ def test_default_configuration():
"task": "galaxy.cleanup_short_term_storage",
"schedule": galaxy_conf.short_term_storage_cleanup_interval,
}
- assert conf.beat_schedule["cleanup-expired-notifications"] == {
- "task": "galaxy.cleanup_expired_notifications",
- "schedule": galaxy_conf.expired_notifications_cleanup_interval,
- }
def test_galaxycelery_trim_module_name():
From 349c373a99aea62b6ee48a621068314f13be13f3 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Fri, 5 Apr 2024 12:27:13 +0200
Subject: [PATCH 16/31] Run notification integration tests without celery
---
test/integration/test_notifications.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/test/integration/test_notifications.py b/test/integration/test_notifications.py
index ed8cc6009f9..519e8442000 100644
--- a/test/integration/test_notifications.py
+++ b/test/integration/test_notifications.py
@@ -52,6 +52,7 @@ class TestNotificationsIntegration(IntegrationTestCase):
@classmethod
def handle_galaxy_config_kwds(cls, config):
super().handle_galaxy_config_kwds(config)
+ config["enable_celery_tasks"] = False
config["enable_notification_system"] = True
def setUp(self):
From 8b4b3858b544a11045243613ffe192eadd251ad9 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Fri, 5 Apr 2024 13:09:01 +0200
Subject: [PATCH 17/31] Revert utcnow refactoring
Is not yet deprecated in Python 3.7
---
lib/galaxy/managers/notification.py | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index a3146158eae..ac58ab43f8a 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -1,8 +1,5 @@
import logging
-from datetime import (
- datetime,
- UTC,
-)
+from datetime import datetime
from enum import Enum
from typing import (
cast,
@@ -128,7 +125,7 @@ class NotificationManager:
@property
def _now(self):
- return datetime.now(UTC)
+ return datetime.utcnow()
@property
def _notification_is_active(self):
From 871ccc685d82755ee829f4568c65660586538004 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Fri, 5 Apr 2024 15:47:41 +0200
Subject: [PATCH 18/31] Update client schema
---
client/src/api/schema/schema.ts | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts
index e5ef8683f46..dbc4567a868 100644
--- a/client/src/api/schema/schema.ts
+++ b/client/src/api/schema/schema.ts
@@ -9598,6 +9598,7 @@ export interface components {
* Channels
* @description The channels that the user wants to receive notifications from for this category.
* @default {
+ * "email": true,
* "push": true
* }
*/
@@ -9614,6 +9615,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.
From 1e944584615ebeee3b68bb1671b31e52757c6cfb Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Fri, 5 Apr 2024 15:52:19 +0200
Subject: [PATCH 19/31] Handle channel settings depending on server config
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.
---
client/src/api/notifications.preferences.ts | 15 +++--
client/src/api/schema/schema.ts | 6 ++
.../NotificationsPreferences.vue | 13 ++--
lib/galaxy/managers/notification.py | 64 ++++++++++++-------
lib/galaxy/schema/notifications.py | 5 +-
.../webapps/galaxy/api/notifications.py | 14 +++-
6 files changed, 80 insertions(+), 37 deletions(-)
diff --git a/client/src/api/notifications.preferences.ts b/client/src/api/notifications.preferences.ts
index 59b910d8c34..8509d7cd269 100644
--- a/client/src/api/notifications.preferences.ts
+++ b/client/src/api/notifications.preferences.ts
@@ -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 {
+ const { data, headers } = await getNotificationsPreferences({});
+ return {
+ ...data,
+ supportedChannels: headers.get("supported-channels")?.split(",") ?? [],
+ };
}
type UpdateUserNotificationPreferencesRequest = components["schemas"]["UpdateUserNotificationPreferencesRequest"];
diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts
index dbc4567a868..8786ebbb1e4 100644
--- a/client/src/api/schema/schema.ts
+++ b/client/src/api/schema/schema.ts
@@ -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"];
/**
@@ -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. */
diff --git a/client/src/components/User/Notifications/NotificationsPreferences.vue b/client/src/components/User/Notifications/NotificationsPreferences.vue
index 5d6f0c3ca01..48487103bcb 100644
--- a/client/src/components/User/Notifications/NotificationsPreferences.vue
+++ b/client/src/components/User/Notifications/NotificationsPreferences.vue
@@ -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";
@@ -39,14 +39,15 @@ const { config } = useConfig(true);
const loading = ref(false);
const errorMessage = ref(null);
const pushNotificationsGranted = ref(pushNotificationsEnabled());
-const notificationsPreferences = ref({});
+const notificationsPreferences = ref({});
+const supportedChannels = ref([]);
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 = {
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.",
};
@@ -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) => {
@@ -148,10 +150,7 @@ watch(
switch />
-
+
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."""
@@ -113,11 +128,7 @@ class NotificationManager:
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):
@@ -407,12 +418,30 @@ class NotificationManager:
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()
+ preferences = self.get_user_notification_preferences(user)
+ preferences.update(request.preferences)
+ user.preferences[NOTIFICATION_PREFERENCES_SECTION_NAME] = preferences.model_dump_json()
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:
"""
@@ -490,9 +519,8 @@ class NotificationManager:
return stmt
-class NotificationRecipientResolverStrategy(Protocol):
- def resolve_users(self, recipients: NotificationRecipients) -> List[User]:
- pass
+# --------------------------------------
+# Notification Recipients Resolver Implementations
class NotificationRecipientResolver:
@@ -601,17 +629,7 @@ class RecursiveCTEStrategy(NotificationRecipientResolverStrategy):
# --------------------------------------
-# 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):
diff --git a/lib/galaxy/schema/notifications.py b/lib/galaxy/schema/notifications.py
index bbf19a9bc39..f78bc095448 100644
--- a/lib/galaxy/schema/notifications.py
+++ b/lib/galaxy/schema/notifications.py
@@ -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?
diff --git a/lib/galaxy/webapps/galaxy/api/notifications.py b/lib/galaxy/webapps/galaxy/api/notifications.py
index dd23fa05031..f1bfbcab88a 100644
--- a/lib/galaxy/webapps/galaxy/api/notifications.py
+++ b/lib/galaxy/webapps/galaxy/api/notifications.py
@@ -70,10 +70,20 @@ class FastAPINotifications:
)
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",
From f43b3856f27b306359429149e0b25a8296a9ba2a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20L=C3=B3pez?=
<46503462+davelopez@users.noreply.github.com>
Date: Sat, 6 Apr 2024 11:11:39 +0200
Subject: [PATCH 20/31] Remove redundant nullable in dispatched column
Co-authored-by: John Davis
---
lib/galaxy/model/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py
index 25e659f5de2..8d191b05ff7 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -2947,7 +2947,7 @@ class Notification(Base, Dictifiable, RepresentById):
String(16), index=True, nullable=True
) # Defines the 'importance' of the notification ('info', 'warning', 'urgent', etc.). Used for filtering, highlight rendering, etc
dispatched: Mapped[Boolean] = mapped_column(
- Boolean, index=True, nullable=False, default=False
+ Boolean, index=True, default=False
) # Whether the notification has been dispatched to users via other channels
# A bug in early 23.1 led to values being stored as json string, so we use this special type to process the result value twice.
# content should always be a dict
From 452669d272d3d5a6d680a6cf9c32d245e5713cc5 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Mon, 8 Apr 2024 18:17:33 +0200
Subject: [PATCH 21/31] Fix generic types in notifications schema
Workaround issues with generics auto-generated names when building the client schema.
---
client/src/api/schema/schema.ts | 64 +++++++++----------
lib/galaxy/schema/notifications.py | 8 ++-
.../webapps/galaxy/services/notifications.py | 4 +-
3 files changed, 37 insertions(+), 39 deletions(-)
diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts
index 8786ebbb1e4..fa83e184e89 100644
--- a/client/src/api/schema/schema.ts
+++ b/client/src/api/schema/schema.ts
@@ -5394,40 +5394,6 @@ export interface components {
/** Tags */
tags?: string[] | null;
};
- /** GenericNotificationCreateRequest[Annotated[int, BeforeValidator, BeforeValidator, PlainSerializer, WithJsonSchema, WithJsonSchema]] */
- GenericNotificationCreateRequest_Annotated_int__BeforeValidator__BeforeValidator__PlainSerializer__WithJsonSchema__WithJsonSchema__: {
- /**
- * Notification
- * @description The notification to create. The structure depends on the category.
- */
- notification: components["schemas"]["NotificationCreateData"];
- /**
- * Recipients
- * @description The recipients of the notification. Can be a combination of users, groups and roles.
- */
- recipients: components["schemas"]["GenericNotificationRecipients_Annotated_int__BeforeValidator__BeforeValidator__PlainSerializer__WithJsonSchema__WithJsonSchema__"];
- };
- /** GenericNotificationRecipients[Annotated[int, BeforeValidator, BeforeValidator, PlainSerializer, WithJsonSchema, WithJsonSchema]] */
- GenericNotificationRecipients_Annotated_int__BeforeValidator__BeforeValidator__PlainSerializer__WithJsonSchema__WithJsonSchema__: {
- /**
- * Group IDs
- * @description The list of encoded group IDs of the groups that should receive the notification.
- * @default []
- */
- group_ids?: string[];
- /**
- * Role IDs
- * @description The list of encoded role IDs of the roles that should receive the notification.
- * @default []
- */
- role_ids?: string[];
- /**
- * User IDs
- * @description The list of encoded user IDs of the users that should receive the notification.
- * @default []
- */
- user_ids?: string[];
- };
/**
* GroupCreatePayload
* @description Payload schema for creating a group.
@@ -9673,7 +9639,14 @@ export interface components {
variant: components["schemas"]["NotificationVariant"];
};
/** NotificationCreateRequestBody */
- NotificationCreateRequestBody: components["schemas"]["GenericNotificationCreateRequest_Annotated_int__BeforeValidator__BeforeValidator__PlainSerializer__WithJsonSchema__WithJsonSchema__"];
+ NotificationCreateRequestBody: {
+ /**
+ * Notification
+ * @description The notification to create. The structure depends on the category.
+ */
+ notification: components["schemas"]["NotificationCreateData"];
+ recipients: components["schemas"]["NotificationRecipientsPayload"];
+ };
/** NotificationCreatedResponse */
NotificationCreatedResponse: {
/**
@@ -9687,6 +9660,27 @@ export interface components {
*/
total_notifications_sent: number;
};
+ /** NotificationRecipientsPayload */
+ NotificationRecipientsPayload: {
+ /**
+ * Group IDs
+ * @description The list of encoded group IDs of the groups that should receive the notification.
+ * @default []
+ */
+ group_ids?: string[];
+ /**
+ * Role IDs
+ * @description The list of encoded role IDs of the roles that should receive the notification.
+ * @default []
+ */
+ role_ids?: string[];
+ /**
+ * User IDs
+ * @description The list of encoded user IDs of the users that should receive the notification.
+ * @default []
+ */
+ user_ids?: string[];
+ };
/**
* NotificationResponse
* @description Basic common fields for all notification responses.
diff --git a/lib/galaxy/schema/notifications.py b/lib/galaxy/schema/notifications.py
index f78bc095448..cb62d4ac92c 100644
--- a/lib/galaxy/schema/notifications.py
+++ b/lib/galaxy/schema/notifications.py
@@ -303,8 +303,12 @@ NotificationCreateRequest = GenericNotificationCreateRequest[int]
NotificationRecipients = GenericNotificationRecipients[int]
-class NotificationCreateRequestBody(RootModel):
- root: GenericNotificationCreateRequest[DecodedDatabaseIdField]
+class NotificationRecipientsPayload(GenericNotificationRecipients[DecodedDatabaseIdField]):
+ pass
+
+
+class NotificationCreateRequestBody(GenericNotificationCreateRequest[DecodedDatabaseIdField]):
+ recipients: NotificationRecipientsPayload
class BroadcastNotificationCreateRequest(NotificationCreateData):
diff --git a/lib/galaxy/webapps/galaxy/services/notifications.py b/lib/galaxy/webapps/galaxy/services/notifications.py
index 70d35c92ed4..f0abdba6eef 100644
--- a/lib/galaxy/webapps/galaxy/services/notifications.py
+++ b/lib/galaxy/webapps/galaxy/services/notifications.py
@@ -57,8 +57,8 @@ class NotificationService(ServiceBase):
self.notification_manager.ensure_notifications_enabled()
self._ensure_user_can_send_notifications(sender_context)
request = NotificationCreateRequest.model_construct(
- notification=payload.root.notification,
- recipients=payload.root.recipients,
+ notification=payload.notification,
+ recipients=payload.recipients,
)
return self.send_notification_internal(request)
From ba0a0abcfcf05f1d9a144acd29613fd493723bc7 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Tue, 9 Apr 2024 13:01:52 +0200
Subject: [PATCH 22/31] Refactor `GenericModel` for reusability
---
client/src/api/notifications.ts | 2 +-
client/src/api/schema/schema.ts | 16 +++++----
lib/galaxy/managers/notification.py | 6 ++--
lib/galaxy/schema/generics.py | 52 +++++++++++++++++++++++++++
lib/galaxy/schema/invocation.py | 38 +++-----------------
lib/galaxy/schema/notifications.py | 21 +++++------
lib/galaxy/webapps/galaxy/fast_app.py | 2 +-
7 files changed, 79 insertions(+), 58 deletions(-)
create mode 100644 lib/galaxy/schema/generics.py
diff --git a/client/src/api/notifications.ts b/client/src/api/notifications.ts
index c5c0fcab813..b5724618e3a 100644
--- a/client/src/api/notifications.ts
+++ b/client/src/api/notifications.ts
@@ -25,7 +25,7 @@ export type NewSharedItemNotificationContentItemType =
type UserNotificationUpdateRequest = components["schemas"]["UserNotificationUpdateRequest"];
-export type NotificationCreateRequest = components["schemas"]["NotificationCreateRequestBody"];
+export type NotificationCreateRequest = components["schemas"]["NotificationCreateRequest"];
type NotificationResponse = components["schemas"]["NotificationResponse"];
diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts
index fa83e184e89..20fa4e2ce5e 100644
--- a/client/src/api/schema/schema.ts
+++ b/client/src/api/schema/schema.ts
@@ -9638,14 +9638,18 @@ export interface components {
*/
variant: components["schemas"]["NotificationVariant"];
};
- /** NotificationCreateRequestBody */
- NotificationCreateRequestBody: {
+ /** NotificationCreateRequest */
+ NotificationCreateRequest: {
/**
* Notification
* @description The notification to create. The structure depends on the category.
*/
notification: components["schemas"]["NotificationCreateData"];
- recipients: components["schemas"]["NotificationRecipientsPayload"];
+ /**
+ * Recipients
+ * @description The recipients of the notification. Can be a combination of users, groups and roles.
+ */
+ recipients: components["schemas"]["NotificationRecipientsRequest"];
};
/** NotificationCreatedResponse */
NotificationCreatedResponse: {
@@ -9660,8 +9664,8 @@ export interface components {
*/
total_notifications_sent: number;
};
- /** NotificationRecipientsPayload */
- NotificationRecipientsPayload: {
+ /** NotificationRecipientsRequest */
+ NotificationRecipientsRequest: {
/**
* Group IDs
* @description The list of encoded group IDs of the groups that should receive the notification.
@@ -19976,7 +19980,7 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["NotificationCreateRequestBody"];
+ "application/json": components["schemas"]["NotificationCreateRequest"];
};
};
responses: {
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index 7f64552ea7c..756cabf1c0b 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -52,7 +52,7 @@ from galaxy.model.scoped_session import galaxy_scoped_session
from galaxy.schema.notifications import (
AnyNotificationContent,
BroadcastNotificationCreateRequest,
- GenericNotificationCreateRequest,
+ GenericNotificationCreate,
MandatoryNotificationCategory,
MessageNotificationContent,
NewSharedItemNotificationContent,
@@ -154,9 +154,7 @@ class NotificationManager:
def can_send_notifications_async(self):
return self.config.enable_celery_tasks
- def send_notification_to_recipients(
- self, request: GenericNotificationCreateRequest
- ) -> Tuple[Optional[Notification], int]:
+ def send_notification_to_recipients(self, request: GenericNotificationCreate) -> Tuple[Optional[Notification], int]:
"""
Creates a new notification and associates it with all the recipient users.
diff --git a/lib/galaxy/schema/generics.py b/lib/galaxy/schema/generics.py
new file mode 100644
index 00000000000..6eca4483505
--- /dev/null
+++ b/lib/galaxy/schema/generics.py
@@ -0,0 +1,52 @@
+from typing import (
+ Any,
+ Tuple,
+ Type,
+ TypeVar,
+)
+
+from pydantic import BaseModel
+from pydantic.json_schema import GenerateJsonSchema
+
+from galaxy.schema.fields import (
+ DecodedDatabaseIdField,
+ EncodedDatabaseIdField,
+)
+
+DatabaseIdT = TypeVar("DatabaseIdT")
+
+ref_to_name = {}
+
+
+class GenericModel(BaseModel):
+ @classmethod
+ def model_parametrized_name(cls, params: Tuple[Type[Any], ...]) -> str:
+ suffix = cls.__determine_suffix__(params)
+ class_name = cls.__name__.split("Generic", 1)[-1]
+ return f"{class_name}{suffix}"
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, *args, **kwargs):
+ result = super().__get_pydantic_core_schema__(*args, **kwargs)
+ ref_to_name[result["ref"]] = cls.__name__
+ return result
+
+ @classmethod
+ def __determine_suffix__(cls, params: Tuple[Type[Any], ...]) -> str:
+ suffix = "Incoming"
+ if params[0] is EncodedDatabaseIdField:
+ suffix = "Response"
+ elif params[0] is DecodedDatabaseIdField:
+ suffix = "Request"
+ return suffix
+
+
+class CustomJsonSchema(GenerateJsonSchema):
+ def get_defs_ref(self, core_mode_ref):
+ full_def = super().get_defs_ref(core_mode_ref)
+ choices = self._prioritized_defsref_choices[full_def]
+ ref, mode = core_mode_ref
+ if ref in ref_to_name:
+ for i, choice in enumerate(choices):
+ choices[i] = choice.replace(choices[0], ref_to_name[ref]) # type: ignore[call-overload]
+ return full_def
diff --git a/lib/galaxy/schema/invocation.py b/lib/galaxy/schema/invocation.py
index df81f450de3..44741831c8f 100644
--- a/lib/galaxy/schema/invocation.py
+++ b/lib/galaxy/schema/invocation.py
@@ -6,9 +6,6 @@ from typing import (
Generic,
List,
Optional,
- Tuple,
- Type,
- TypeVar,
Union,
)
@@ -20,7 +17,6 @@ from pydantic import (
UUID1,
UUID4,
)
-from pydantic.json_schema import GenerateJsonSchema
from typing_extensions import (
Annotated,
Literal,
@@ -33,6 +29,10 @@ from galaxy.schema.fields import (
literal_to_value,
ModelClassField,
)
+from galaxy.schema.generics import (
+ DatabaseIdT,
+ GenericModel,
+)
from galaxy.schema.schema import (
CreateTimeField,
DataItemSourceType,
@@ -86,38 +86,10 @@ class CancelReason(str, Enum):
cancelled_on_review = "cancelled_on_review"
-DatabaseIdT = TypeVar("DatabaseIdT")
-
-ref_to_name = {}
-
-
-class InvocationMessageBase(BaseModel):
+class InvocationMessageBase(GenericModel):
reason: Union[CancelReason, FailureReason, WarningReason]
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
- @classmethod
- def model_parametrized_name(cls, params: Tuple[Type[Any], ...]) -> str:
- suffix = "Response" if params[0] is EncodedDatabaseIdField else "Incoming"
- class_name = cls.__name__.split("Generic", 1)[-1]
- return f"{class_name}{suffix}"
-
- @classmethod
- def __get_pydantic_core_schema__(cls, *args, **kwargs):
- result = super().__get_pydantic_core_schema__(*args, **kwargs)
- ref_to_name[result["ref"]] = cls.__name__
- return result
-
-
-class CustomJsonSchema(GenerateJsonSchema):
- def get_defs_ref(self, core_mode_ref):
- full_def = super().get_defs_ref(core_mode_ref)
- choices = self._prioritized_defsref_choices[full_def]
- ref, mode = core_mode_ref
- if ref in ref_to_name:
- for i, choice in enumerate(choices):
- choices[i] = choice.replace(choices[0], ref_to_name[ref]) # type: ignore[call-overload]
- return full_def
-
class GenericInvocationCancellationReviewFailed(InvocationMessageBase, Generic[DatabaseIdT]):
reason: Literal[CancelReason.cancelled_on_review]
diff --git a/lib/galaxy/schema/notifications.py b/lib/galaxy/schema/notifications.py
index cb62d4ac92c..c6ff09f2f57 100644
--- a/lib/galaxy/schema/notifications.py
+++ b/lib/galaxy/schema/notifications.py
@@ -6,7 +6,6 @@ from typing import (
Generic,
List,
Optional,
- TypeVar,
Union,
)
@@ -24,6 +23,10 @@ from galaxy.schema.fields import (
DecodedDatabaseIdField,
EncodedDatabaseIdField,
)
+from galaxy.schema.generics import (
+ DatabaseIdT,
+ GenericModel,
+)
from galaxy.schema.schema import Model
from galaxy.schema.types import (
AbsoluteOrRelativeUrl,
@@ -261,10 +264,7 @@ class NotificationCreateData(Model):
)
-DatabaseIdT = TypeVar("DatabaseIdT")
-
-
-class GenericNotificationRecipients(Model, Generic[DatabaseIdT]):
+class GenericNotificationRecipients(GenericModel, Generic[DatabaseIdT]):
"""The recipients of a notification. Can be a combination of users, groups and roles."""
user_ids: List[DatabaseIdT] = Field(
@@ -284,7 +284,7 @@ class GenericNotificationRecipients(Model, Generic[DatabaseIdT]):
)
-class GenericNotificationCreateRequest(Model, Generic[DatabaseIdT]):
+class GenericNotificationCreate(GenericModel, Generic[DatabaseIdT]):
"""Contains the recipients and the notification to create."""
recipients: GenericNotificationRecipients[DatabaseIdT] = Field(
@@ -299,16 +299,11 @@ class GenericNotificationCreateRequest(Model, Generic[DatabaseIdT]):
)
-NotificationCreateRequest = GenericNotificationCreateRequest[int]
+NotificationCreateRequest = GenericNotificationCreate[int]
NotificationRecipients = GenericNotificationRecipients[int]
-class NotificationRecipientsPayload(GenericNotificationRecipients[DecodedDatabaseIdField]):
- pass
-
-
-class NotificationCreateRequestBody(GenericNotificationCreateRequest[DecodedDatabaseIdField]):
- recipients: NotificationRecipientsPayload
+NotificationCreateRequestBody = GenericNotificationCreate[DecodedDatabaseIdField]
class BroadcastNotificationCreateRequest(NotificationCreateData):
diff --git a/lib/galaxy/webapps/galaxy/fast_app.py b/lib/galaxy/webapps/galaxy/fast_app.py
index 0a2e0e26ec1..4f3a70ec5ac 100644
--- a/lib/galaxy/webapps/galaxy/fast_app.py
+++ b/lib/galaxy/webapps/galaxy/fast_app.py
@@ -12,7 +12,7 @@ from fastapi.openapi.constants import REF_TEMPLATE
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import Response
-from galaxy.schema.invocation import CustomJsonSchema
+from galaxy.schema.generics import CustomJsonSchema
from galaxy.version import VERSION
from galaxy.webapps.base.api import (
add_exception_handler,
From 11ca5473ea7c72f23e5471996c7195712f32e975 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Tue, 9 Apr 2024 15:55:05 +0200
Subject: [PATCH 23/31] Allow to `force_sync` for creating internal
notifications
This is useful when we know in advance the list of recipient users and that no group or roles need to be resolved. Like when we share an item with a list of users.
---
lib/galaxy/webapps/galaxy/services/notifications.py | 7 +++++--
lib/galaxy/webapps/galaxy/services/sharable.py | 4 +++-
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/lib/galaxy/webapps/galaxy/services/notifications.py b/lib/galaxy/webapps/galaxy/services/notifications.py
index f0abdba6eef..b6de62ac1ec 100644
--- a/lib/galaxy/webapps/galaxy/services/notifications.py
+++ b/lib/galaxy/webapps/galaxy/services/notifications.py
@@ -63,13 +63,16 @@ class NotificationService(ServiceBase):
return self.send_notification_internal(request)
def send_notification_internal(
- self, request: NotificationCreateRequest
+ self, request: NotificationCreateRequest, force_sync: bool = False
) -> Union[NotificationCreatedResponse, AsyncTaskResultSummary]:
"""Sends a notification to a list of recipients (users, groups or roles).
+ If `force_sync` is set to `True`, the notification recipients will be processed synchronously instead of
+ in a background task.
+
Note: This function is meant for internal use from other services that don't need to check sender permissions.
"""
- if self.notification_manager.can_send_notifications_async:
+ if self.notification_manager.can_send_notifications_async and not force_sync:
result = send_notification_to_recipients_async.delay(request)
summary = async_task_summary(result)
return summary
diff --git a/lib/galaxy/webapps/galaxy/services/sharable.py b/lib/galaxy/webapps/galaxy/services/sharable.py
index 7d5bc5cfe54..e1a8328b700 100644
--- a/lib/galaxy/webapps/galaxy/services/sharable.py
+++ b/lib/galaxy/webapps/galaxy/services/sharable.py
@@ -180,7 +180,9 @@ class ShareableService:
and users_to_notify
):
request = SharedItemNotificationFactory.build_notification_request(item, users_to_notify, status)
- self.notification_service.send_notification_internal(request)
+ # We can set force_sync=True here because we already have the set of users to notify
+ # and there is no need to resolve them asynchronously as no groups or roles are involved.
+ self.notification_service.send_notification_internal(request, force_sync=True)
class SharedItemNotificationFactory:
From 123904aced6ade66b6339ff4289f072c57c1a1c8 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Tue, 9 Apr 2024 15:56:27 +0200
Subject: [PATCH 24/31] Adapt notification integration tests to work w/o tasks
---
test/integration/test_notifications.py | 83 +++++++++++++++++++++-----
1 file changed, 67 insertions(+), 16 deletions(-)
diff --git a/test/integration/test_notifications.py b/test/integration/test_notifications.py
index 519e8442000..d44fcaea8da 100644
--- a/test/integration/test_notifications.py
+++ b/test/integration/test_notifications.py
@@ -44,7 +44,7 @@ def notification_broadcast_test_data(subject: Optional[str] = None, message: Opt
}
-class TestNotificationsIntegration(IntegrationTestCase):
+class NotificationsIntegrationBase(IntegrationTestCase):
dataset_populator: DatasetPopulator
task_based = False
framework_tool_and_types = False
@@ -67,12 +67,18 @@ class TestNotificationsIntegration(IntegrationTestCase):
before_creating_notifications = datetime.utcnow()
# Only user1 will receive this notification
- created_response_1 = self._send_test_notification_to([user1["id"]], message="test_notification_status 1")
- assert created_response_1["total_notifications_sent"] == 1
+ subject1 = f"notification_{uuid4()}"
+ created_response_1 = self._send_test_notification_to(
+ [user1["id"]], subject=subject1, message="test_notification_status 1"
+ )
+ self._assert_notifications_sent(created_response_1, expected_count=1)
# Both user1 and user2 will receive this notification
- created_response_2 = self._send_test_notification_to([user1["id"], user2["id"]], "test_notification_status 2")
- assert created_response_2["total_notifications_sent"] == 2
+ subject2 = f"notification_{uuid4()}"
+ created_response_2 = self._send_test_notification_to(
+ [user1["id"], user2["id"]], subject=subject2, message="test_notification_status 2"
+ )
+ self._assert_notifications_sent(created_response_2, expected_count=2)
# All users will receive this broadcasted notification
created_response_3 = self._send_broadcast_notification("test_notification_status 3")
@@ -114,7 +120,8 @@ class TestNotificationsIntegration(IntegrationTestCase):
assert len(status["broadcasts"]) == 0
# Updating a notification association value should return that notification in the next request
- notification_id = created_response_2["notification"]["id"]
+ notification_id = self._get_notification_id_by_subject(subject2)
+ assert notification_id is not None
self._update_notification(notification_id, update_state={"seen": True})
status = self._get_notifications_status_since(after_creating_notifications)
assert status["total_unread_count"] == 0
@@ -132,16 +139,21 @@ class TestNotificationsIntegration(IntegrationTestCase):
user1 = self._create_test_user()
user2 = self._create_test_user()
+ subject = f"notification_{uuid4()}"
created_response = self._send_test_notification_to(
- [user1["id"]], message="test_user_cannot_access_other_users_notifications"
+ [user1["id"]], subject=subject, message="test_user_cannot_access_other_users_notifications"
)
- notification_id = created_response["notification"]["id"]
+ self._assert_notifications_sent(created_response, expected_count=1)
+ notification_id = None
with self._different_user(user1["email"]):
+ notification_id = self._get_notification_id_by_subject(subject)
+ assert notification_id is not None
response = self._get(f"notifications/{notification_id}")
self._assert_status_code_is_ok(response)
with self._different_user(user2["email"]):
+ assert notification_id is not None
response = self._get(f"notifications/{notification_id}")
self._assert_status_code_is(response, 404)
@@ -151,13 +163,15 @@ class TestNotificationsIntegration(IntegrationTestCase):
before_creating_notifications = datetime.utcnow()
+ subject = f"notification_{uuid4()}"
created_response = self._send_test_notification_to(
- [user1["id"], user2["id"]], message="test_delete_notification_by_user"
+ [user1["id"], user2["id"]], subject=subject, message="test_delete_notification_by_user"
)
- assert created_response["total_notifications_sent"] == 2
- notification_id = created_response["notification"]["id"]
+ self._assert_notifications_sent(created_response, expected_count=2)
with self._different_user(user1["email"]):
+ notification_id = self._get_notification_id_by_subject(subject)
+ assert notification_id is not None
response = self._get(f"notifications/{notification_id}")
self._assert_status_code_is_ok(response)
self._delete(f"notifications/{notification_id}")
@@ -170,6 +184,7 @@ class TestNotificationsIntegration(IntegrationTestCase):
assert len(status["broadcasts"]) == 0
with self._different_user(user2["email"]):
+ assert notification_id is not None
response = self._get(f"notifications/{notification_id}")
self._assert_status_code_is_ok(response)
@@ -196,11 +211,14 @@ class TestNotificationsIntegration(IntegrationTestCase):
def test_update_notifications(self):
recipient_user = self._create_test_user()
- created_user_notification_response = self._send_test_notification_to(
- [recipient_user["id"]], subject="User Notification to update"
- )
- assert created_user_notification_response["total_notifications_sent"] == 1
- user_notification_id = created_user_notification_response["notification"]["id"]
+ subject = f"User Notification to update {uuid4()}"
+ created_user_notification_response = self._send_test_notification_to([recipient_user["id"]], subject=subject)
+ self._assert_notifications_sent(created_user_notification_response, expected_count=1)
+
+ with self._different_user(recipient_user["email"]):
+ user_notification_id = self._get_notification_id_by_subject(subject)
+
+ assert user_notification_id is not None
created_broadcast_notification_response = self._send_broadcast_notification(
subject="Broadcasted Notification to update"
@@ -403,3 +421,36 @@ class TestNotificationsIntegration(IntegrationTestCase):
def _update_notification(self, notification_id: str, update_state: Dict[str, Any]):
update_response = self._put(f"notifications/{notification_id}", data=update_state, json=True)
self._assert_status_code_is(update_response, 204)
+
+ def _assert_notifications_sent(self, response, expected_count: int = 0):
+ if self.task_based:
+ task_id = response["id"]
+ assert task_id is not None
+ self.dataset_populator.wait_on_task_id(task_id)
+ else:
+ assert response["total_notifications_sent"] == expected_count
+
+ def _get_notification_id_by_subject(self, subject: str) -> Optional[str]:
+ notifications = self._get("notifications").json()
+ for notification in notifications:
+ if notification["content"]["subject"] == subject:
+ return notification["id"]
+ return None
+
+
+class TestNotificationsIntegration(NotificationsIntegrationBase):
+ task_based = False
+
+ @classmethod
+ def handle_galaxy_config_kwds(cls, config):
+ super().handle_galaxy_config_kwds(config)
+ config["enable_celery_tasks"] = False
+
+
+class TestNotificationsIntegrationTaskBased(NotificationsIntegrationBase):
+ task_based = True
+
+ @classmethod
+ def handle_galaxy_config_kwds(cls, config):
+ super().handle_galaxy_config_kwds(config)
+ config["enable_celery_tasks"] = True
From 89d5af413bef9ee317a3b8364338287ea1647675 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 11 Apr 2024 11:05:03 +0200
Subject: [PATCH 25/31] Add db migration for galaxy_url column in notifications
table
---
..._add_galaxy_url_column_to_notifications.py | 36 +++++++++++++++++++
1 file changed, 36 insertions(+)
create mode 100644 lib/galaxy/model/migrations/alembic/versions_gxy/5924fbf10430_add_galaxy_url_column_to_notifications.py
diff --git a/lib/galaxy/model/migrations/alembic/versions_gxy/5924fbf10430_add_galaxy_url_column_to_notifications.py b/lib/galaxy/model/migrations/alembic/versions_gxy/5924fbf10430_add_galaxy_url_column_to_notifications.py
new file mode 100644
index 00000000000..1ccdba539ac
--- /dev/null
+++ b/lib/galaxy/model/migrations/alembic/versions_gxy/5924fbf10430_add_galaxy_url_column_to_notifications.py
@@ -0,0 +1,36 @@
+"""add galaxy_url column to notifications
+
+Revision ID: 5924fbf10430
+Revises: 303a5583a030
+Create Date: 2024-04-11 09:56:26.200231
+
+"""
+
+from sqlalchemy import (
+ Column,
+ String,
+)
+
+from galaxy.model.migrations.util import (
+ add_column,
+ drop_column,
+)
+
+# revision identifiers, used by Alembic.
+revision = "5924fbf10430"
+down_revision = "303a5583a030"
+branch_labels = None
+depends_on = None
+
+
+# database object names used in this revision
+table_name = "notification"
+column_name = "galaxy_url"
+
+
+def upgrade():
+ add_column(table_name, Column(column_name, String(255)))
+
+
+def downgrade():
+ drop_column(table_name, column_name)
From f083768fd9ff794ddcd361db41daa006d9d6c30c Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 11 Apr 2024 11:07:58 +0200
Subject: [PATCH 26/31] Add galaxy_url mapped column to notification model
---
lib/galaxy/model/__init__.py | 3 +++
1 file changed, 3 insertions(+)
diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py
index 8d191b05ff7..2a84ee0ac00 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -2949,6 +2949,9 @@ class Notification(Base, Dictifiable, RepresentById):
dispatched: Mapped[Boolean] = mapped_column(
Boolean, index=True, default=False
) # Whether the notification has been dispatched to users via other channels
+ galaxy_url: Mapped[Optional[str]] = mapped_column(
+ String(255)
+ ) # The URL to the Galaxy instance, used for generating links in the notification
# A bug in early 23.1 led to values being stored as json string, so we use this special type to process the result value twice.
# content should always be a dict
content: Mapped[Optional[bytes]] = mapped_column(DoubleEncodedJsonType)
From 34461f2c1ace6c5574d39bfc2f47d673e8b6b343 Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 11 Apr 2024 12:23:06 +0200
Subject: [PATCH 27/31] Update notification creation and sharing logic
This commit updates the notification creation and sharing logic to include the `galaxy_url` field in the `NotificationCreateRequest` and `SharedItemNotificationFactory` classes. This field is used to generate links in the notification content and is passed as an argument when sending notifications to recipients.
---
lib/galaxy/managers/notification.py | 28 +++++++++++++------
lib/galaxy/schema/notifications.py | 9 +++++-
.../webapps/galaxy/services/notifications.py | 4 +++
.../webapps/galaxy/services/sharable.py | 14 +++++++---
.../app/managers/test_NotificationManager.py | 1 +
5 files changed, 43 insertions(+), 13 deletions(-)
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index 756cabf1c0b..8682d1fc0d4 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -11,6 +11,7 @@ from typing import (
Tuple,
Type,
)
+from urllib.parse import urlparse
from pydantic import (
BaseModel,
@@ -52,7 +53,6 @@ from galaxy.model.scoped_session import galaxy_scoped_session
from galaxy.schema.notifications import (
AnyNotificationContent,
BroadcastNotificationCreateRequest,
- GenericNotificationCreate,
MandatoryNotificationCategory,
MessageNotificationContent,
NewSharedItemNotificationContent,
@@ -60,6 +60,7 @@ from galaxy.schema.notifications import (
NotificationCategorySettings,
NotificationChannelSettings,
NotificationCreateData,
+ NotificationCreateRequest,
NotificationRecipients,
PersonalNotificationCategory,
UpdateUserNotificationPreferencesRequest,
@@ -154,7 +155,7 @@ class NotificationManager:
def can_send_notifications_async(self):
return self.config.enable_celery_tasks
- def send_notification_to_recipients(self, request: GenericNotificationCreate) -> Tuple[Optional[Notification], int]:
+ def send_notification_to_recipients(self, request: NotificationCreateRequest) -> Tuple[Optional[Notification], int]:
"""
Creates a new notification and associates it with all the recipient users.
@@ -163,7 +164,7 @@ class NotificationManager:
"""
self.ensure_notifications_enabled()
recipient_users = self.recipient_resolver.resolve(request.recipients)
- notification = self._create_notification_model(request.notification)
+ notification = self._create_notification_model(request.notification, request.galaxy_url)
self.sa_session.add(notification)
with transaction(self.sa_session):
self.sa_session.commit()
@@ -463,7 +464,9 @@ class NotificationManager:
return CleanupResultSummary(deleted_notifications_count, deleted_associations_count)
- def _create_notification_model(self, payload: NotificationCreateData):
+ def _create_notification_model(
+ self, payload: NotificationCreateData, galaxy_url: Optional[str] = None
+ ) -> Notification:
notification = Notification(
payload.source,
payload.category,
@@ -472,6 +475,7 @@ class NotificationManager:
)
notification.publication_time = payload.publication_time
notification.expiration_time = payload.expiration_time
+ notification.galaxy_url = galaxy_url
return notification
def _user_notifications_query(
@@ -650,6 +654,7 @@ class NotificationContext(BaseModel):
contact_email: str
notification_settings_url: str
content: AnyNotificationContent
+ galaxy_url: Optional[str] = None
class EmailNotificationTemplateBuilder(Protocol):
@@ -680,15 +685,22 @@ class EmailNotificationTemplateBuilder(Protocol):
notification = self.notification
user = self.user
notification_date = notification.publication_time if notification.publication_time else notification.create_time
+ hostname = (
+ urlparse(self.notification.galaxy_url).hostname if self.notification.galaxy_url else self.config.server_name
+ )
+ notification_settings_url = (
+ f"{self.notification.galaxy_url}/user/notifications" if self.notification.galaxy_url else None
+ )
+ contact_email = self.config.error_email_to or None
return NotificationContext(
name=user.username,
user_email=user.email,
date=notification_date.strftime("%B %d, %Y"),
- hostname=self.config.server_name,
- contact_email=self.config.error_email_to or "",
- # TODO: How to build the proper URL without access to trans?
- notification_settings_url=f"https://{self.config.server_name}/user/notifications",
+ hostname=hostname,
+ contact_email=contact_email,
+ notification_settings_url=notification_settings_url,
content=self.get_content(template_format),
+ galaxy_url=self.notification.galaxy_url,
)
def get_body(self, template_format: TemplateFormats) -> str:
diff --git a/lib/galaxy/schema/notifications.py b/lib/galaxy/schema/notifications.py
index c6ff09f2f57..941752dc25e 100644
--- a/lib/galaxy/schema/notifications.py
+++ b/lib/galaxy/schema/notifications.py
@@ -299,7 +299,14 @@ class GenericNotificationCreate(GenericModel, Generic[DatabaseIdT]):
)
-NotificationCreateRequest = GenericNotificationCreate[int]
+class NotificationCreateRequest(GenericNotificationCreate[int]):
+ galaxy_url: Optional[str] = Field(
+ None,
+ title="Galaxy URL",
+ description="The URL of the Galaxy instance. Used to generate links in the notification content.",
+ )
+
+
NotificationRecipients = GenericNotificationRecipients[int]
diff --git a/lib/galaxy/webapps/galaxy/services/notifications.py b/lib/galaxy/webapps/galaxy/services/notifications.py
index b6de62ac1ec..a9b4302a8db 100644
--- a/lib/galaxy/webapps/galaxy/services/notifications.py
+++ b/lib/galaxy/webapps/galaxy/services/notifications.py
@@ -56,9 +56,13 @@ class NotificationService(ServiceBase):
"""
self.notification_manager.ensure_notifications_enabled()
self._ensure_user_can_send_notifications(sender_context)
+ galaxy_url = (
+ str(sender_context.url_builder("/", qualified=True)).rstrip("/") if sender_context.url_builder else None
+ )
request = NotificationCreateRequest.model_construct(
notification=payload.notification,
recipients=payload.recipients,
+ galaxy_url=galaxy_url,
)
return self.send_notification_internal(request)
diff --git a/lib/galaxy/webapps/galaxy/services/sharable.py b/lib/galaxy/webapps/galaxy/services/sharable.py
index e1a8328b700..de318bbfad6 100644
--- a/lib/galaxy/webapps/galaxy/services/sharable.py
+++ b/lib/galaxy/webapps/galaxy/services/sharable.py
@@ -117,7 +117,8 @@ class ShareableService:
base_status = self._get_sharing_status(trans, item)
status = self.share_with_status_cls.model_construct(**base_status.model_dump(), extra=extra)
status.errors.extend(errors)
- self._send_notification_to_users(users_to_notify, item, status)
+ galaxy_url = str(trans.url_builder("/", qualified=True)).rstrip("/") if trans.url_builder else None
+ self._send_notification_to_users(users_to_notify, item, status, galaxy_url)
return status
def _share_with_options(
@@ -173,13 +174,17 @@ class ShareableService:
return send_to_users, send_to_err
- def _send_notification_to_users(self, users_to_notify: Set[User], item: SharableItem, status: ShareWithStatus):
+ def _send_notification_to_users(
+ self, users_to_notify: Set[User], item: SharableItem, status: ShareWithStatus, galaxy_url: Optional[str] = None
+ ):
if (
self.notification_service.notification_manager.notifications_enabled
and not status.errors
and users_to_notify
):
- request = SharedItemNotificationFactory.build_notification_request(item, users_to_notify, status)
+ request = SharedItemNotificationFactory.build_notification_request(
+ item, users_to_notify, status, galaxy_url
+ )
# We can set force_sync=True here because we already have the set of users to notify
# and there is no need to resolve them asynchronously as no groups or roles are involved.
self.notification_service.send_notification_internal(request, force_sync=True)
@@ -197,7 +202,7 @@ class SharedItemNotificationFactory:
@staticmethod
def build_notification_request(
- item: SharableItem, users_to_notify: Set[User], status: ShareWithStatus
+ item: SharableItem, users_to_notify: Set[User], status: ShareWithStatus, galaxy_url: Optional[str] = None
) -> NotificationCreateRequest:
user_ids = [user.id for user in users_to_notify]
request = NotificationCreateRequest(
@@ -213,5 +218,6 @@ class SharedItemNotificationFactory:
slug=status.username_and_slug,
),
),
+ galaxy_url=galaxy_url,
)
return request
diff --git a/test/unit/app/managers/test_NotificationManager.py b/test/unit/app/managers/test_NotificationManager.py
index c8c011160a8..d3e23620432 100644
--- a/test/unit/app/managers/test_NotificationManager.py
+++ b/test/unit/app/managers/test_NotificationManager.py
@@ -81,6 +81,7 @@ class NotificationManagerBaseTestCase(NotificationsBaseTestCase):
user_ids=[user.id for user in users],
),
notification=notification_data,
+ galaxy_url="https://test.galaxy.url",
)
created_notification, notifications_sent = self.notification_manager.send_notification_to_recipients(request)
return created_notification, notifications_sent
From de1cf8f96f6a6519c571cdb7dfec5ac57d52abda Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 11 Apr 2024 12:23:34 +0200
Subject: [PATCH 28/31] Update notification email templates
---
.../mail/notifications/message-email.html | 17 ++++++++----
.../mail/notifications/message-email.txt | 20 +++++++++-----
.../notifications/new_shared_item-email.html | 18 ++++++++-----
.../notifications/new_shared_item-email.txt | 27 ++++++++++++-------
lib/galaxy/managers/notification.py | 5 ++--
5 files changed, 59 insertions(+), 28 deletions(-)
diff --git a/lib/galaxy/config/templates/mail/notifications/message-email.html b/lib/galaxy/config/templates/mail/notifications/message-email.html
index c5d4bbde6ae..1ebccda977a 100644
--- a/lib/galaxy/config/templates/mail/notifications/message-email.html
+++ b/lib/galaxy/config/templates/mail/notifications/message-email.html
@@ -13,12 +13,13 @@ 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
+- 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 >>>>>>
@@ -55,14 +56,20 @@ Template begins here >>>>>>
Regards,
- Your Galaxy Team at {{ hostname }}
+ Your Galaxy Team at {{ hostname }}
- This is an automated email. If you have any questions or concerns, please do not reply to this email, instead, contact us at {{ contact_email }}.
-
- You received this email because you are subscribed to receive notifications of this type.
+ 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 here.
+ {% endif %}
+
+
+
+ {% 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 {{ contact_email }}.
+ {% endif %}
>>>>>
Hello {{ name }},
@@ -31,11 +32,18 @@ Subject:
Message:
{{ content['message'] }}
-To manage your notification settings, please visit {{ notification_settings_url }}.
-
-If you have any questions, please contact us at {{ contact_email }}.
-
Thank you for using Galaxy!
Regards,
-Your Galaxy Team
+Your Galaxy Team at {{ hostname }}
+
+---
+
+You received this email because you are subscribed to receive notifications from the Galaxy Team.
+{% if notification_settings_url %}
+To manage your notification settings, please visit {{ notification_settings_url }}.
+{% endif %}
+
+{% 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 {{ contact_email }}.
+{% endif %}
diff --git a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
index 45a610bbb38..9dec5fe3bed 100644
--- a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
+++ b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
@@ -13,7 +13,7 @@ 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
+- 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 new_shared_item payload
@@ -21,6 +21,7 @@ the email is sent:
- item_name The name of the shared item
- owner_name The name of the owner of the shared item
- slug The slug of the shared item. Used for the link to the item.
+- galaxy_url The URL to the Galaxy instance (i.e. https://usegalaxy.*)
Template begins here >>>>>>
@@ -41,7 +42,7 @@ Template begins here >>>>>>
Regards,
- Your Galaxy Team at {{ hostname }}
+ Your Galaxy Team at {{ hostname }}
- This is an automated email. If you have any questions or concerns, please do not reply to this email, instead, contact us at {{ contact_email }}.
-
You received this email because you are subscribed to receive notifications when another user shares an item with you.
-
+ {% if notification_settings_url %}
You can manage your notification settings here.
+ {% endif %}
+
+
+
+ {% 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 {{ contact_email }}.
+ {% endif %}
>>>>>
Hello {{ name }},
-A new {{ content['item_type'] }} has been shared with you by {{ content['owner_name'] }}.
+A new {{ content['item_type'] }} has been shared with you on {{ date }} by {{ content['owner_name'] }}.
To access the shared {{ content['item_type'] }}, please visit the following link:
-{{ hostname }}/{{ content['slug'] }}
-
-You received this email because you are subscribed to receive notifications when another user shares an item with you.
-To manage your notification settings, please visit {{ notification_settings_url }}.
-
-If you have any questions, please contact us at {{ contact_email }}.
+{{ galaxy_url }}/{{ content['slug'] }}
Thank you for using Galaxy!
Regards,
-Your Galaxy Team
+Your Galaxy Team at {{ hostname }}
+
+---
+
+You received this email because you are subscribed to receive notifications when another user shares an item with you.
+{% if notification_settings_url %}
+To manage your notification settings, please visit {{ notification_settings_url }}.
+{% endif %}
+
+{% 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 {{ contact_email }}.
+{% endif %}
+
+
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index 8682d1fc0d4..d4e08eed007 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -726,7 +726,8 @@ class MessageEmailNotificationTemplateBuilder(EmailNotificationTemplateBuilder):
return content
def get_subject(self) -> str:
- return "[Galaxy] New message received"
+ content = cast(MessageNotificationContent, self.get_content(TemplateFormats.TXT))
+ return f"[Galaxy] New message: {content.subject}"
class NewSharedItemEmailNotificationTemplateBuilder(EmailNotificationTemplateBuilder):
@@ -737,7 +738,7 @@ class NewSharedItemEmailNotificationTemplateBuilder(EmailNotificationTemplateBui
def get_subject(self) -> str:
content = cast(NewSharedItemNotificationContent, self.get_content(TemplateFormats.TXT))
- return f"[Galaxy] New {content.item_type} shared with you"
+ return f"[Galaxy] New {content.item_type} shared with you: {content.item_name}"
class EmailNotificationChannelPlugin(NotificationChannelPlugin):
From 00d98655ffa93b1862057ea9bd924da338fc70bd Mon Sep 17 00:00:00 2001
From: davelopez <46503462+davelopez@users.noreply.github.com>
Date: Thu, 11 Apr 2024 13:16:13 +0200
Subject: [PATCH 29/31] Open notification preferences if specified in route
query
---
.../src/components/Notifications/NotificationsList.vue | 10 +++++++++-
client/src/entry/analysis/router.js | 3 +++
lib/galaxy/managers/notification.py | 4 +++-
3 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/client/src/components/Notifications/NotificationsList.vue b/client/src/components/Notifications/NotificationsList.vue
index 945141215d9..b9268e6c41f 100644
--- a/client/src/components/Notifications/NotificationsList.vue
+++ b/client/src/components/Notifications/NotificationsList.vue
@@ -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(), {
+ shouldOpenPreferences: false,
+});
+
const showUnread = ref(false);
const showShared = ref(false);
-const preferencesOpen = ref(false);
+const preferencesOpen = ref(props.shouldOpenPreferences);
const selectedNotificationIds = ref([]);
const haveSelected = computed(() => selectedNotificationIds.value.length > 0);
diff --git a/client/src/entry/analysis/router.js b/client/src/entry/analysis/router.js
index 0a299591684..dec66182591 100644
--- a/client/src/entry/analysis/router.js
+++ b/client/src/entry/analysis/router.js
@@ -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",
diff --git a/lib/galaxy/managers/notification.py b/lib/galaxy/managers/notification.py
index d4e08eed007..d042780eae3 100644
--- a/lib/galaxy/managers/notification.py
+++ b/lib/galaxy/managers/notification.py
@@ -689,7 +689,9 @@ class EmailNotificationTemplateBuilder(Protocol):
urlparse(self.notification.galaxy_url).hostname if self.notification.galaxy_url else self.config.server_name
)
notification_settings_url = (
- f"{self.notification.galaxy_url}/user/notifications" if self.notification.galaxy_url else None
+ f"{self.notification.galaxy_url}/user/notifications?preferences=true"
+ if self.notification.galaxy_url
+ else None
)
contact_email = self.config.error_email_to or None
return NotificationContext(
From 0a287f20aa888f3d8bff8ddf2f66496443096796 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20L=C3=B3pez?=
<46503462+davelopez@users.noreply.github.com>
Date: Thu, 11 Apr 2024 16:49:18 +0200
Subject: [PATCH 30/31] Use proper type for Mapped
Co-authored-by: John Davis
---
lib/galaxy/model/__init__.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py
index 2a84ee0ac00..a39358987ca 100644
--- a/lib/galaxy/model/__init__.py
+++ b/lib/galaxy/model/__init__.py
@@ -2946,7 +2946,7 @@ class Notification(Base, Dictifiable, RepresentById):
variant: Mapped[str] = mapped_column(
String(16), index=True, nullable=True
) # Defines the 'importance' of the notification ('info', 'warning', 'urgent', etc.). Used for filtering, highlight rendering, etc
- dispatched: Mapped[Boolean] = mapped_column(
+ dispatched: Mapped[bool] = mapped_column(
Boolean, index=True, default=False
) # Whether the notification has been dispatched to users via other channels
galaxy_url: Mapped[Optional[str]] = mapped_column(
From 23164decedb2f20bed341f6f08cff1d140d1ff8c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?David=20L=C3=B3pez?=
<46503462+davelopez@users.noreply.github.com>
Date: Tue, 16 Apr 2024 17:01:42 +0200
Subject: [PATCH 31/31] Apply suggestions from code review
Co-authored-by: John Davis
---
.../config/templates/mail/notifications/message-email.html | 6 +++---
.../config/templates/mail/notifications/message-email.txt | 2 +-
.../templates/mail/notifications/new_shared_item-email.html | 4 ++--
.../templates/mail/notifications/new_shared_item-email.txt | 2 +-
4 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/lib/galaxy/config/templates/mail/notifications/message-email.html b/lib/galaxy/config/templates/mail/notifications/message-email.html
index 1ebccda977a..e690ecc3266 100644
--- a/lib/galaxy/config/templates/mail/notifications/message-email.html
+++ b/lib/galaxy/config/templates/mail/notifications/message-email.html
@@ -1,12 +1,12 @@
-Use this template to customize the HTML email your users will receive
+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 into this, remember that only absolute URLS (with
+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
+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:
diff --git a/lib/galaxy/config/templates/mail/notifications/message-email.txt b/lib/galaxy/config/templates/mail/notifications/message-email.txt
index 1cecfd06a05..2e40a42d185 100644
--- a/lib/galaxy/config/templates/mail/notifications/message-email.txt
+++ b/lib/galaxy/config/templates/mail/notifications/message-email.txt
@@ -2,7 +2,7 @@ Use this template to customize the text 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.txt and modify as required.
-If you are adding URLs into this, remember that only absolute URLS (with
+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.
diff --git a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
index 9dec5fe3bed..cb6905a6ec9 100644
--- a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
+++ b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.html
@@ -2,11 +2,11 @@ Use this template to customize the HTML email your users will receive
when a new notification of category "new_shared_item" is sent to them.
Copy the file to {{ templates_dir }}/mail/notifications/new_shared_item-email.html and modify as required.
-If you are adding URLs into this, remember that only absolute URLS (with
+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
+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:
diff --git a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt
index d38983bf458..32fb37a5b88 100644
--- a/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt
+++ b/lib/galaxy/config/templates/mail/notifications/new_shared_item-email.txt
@@ -2,7 +2,7 @@ Use this template to customize the text email your users will receive
when a new notification of category "new_shared_item" is sent to them.
Copy the file to {{ templates_dir }}/mail/notifications/new_shared_item-email.txt and modify as required.
-If you are adding URLs into this, remember that only absolute URLS (with
+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.