mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Add EmailNotificationChannelPlugin and basic email sending
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user