Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Propose INotifier #8200

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions changes/8200.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Implement interface INotifier to allow extensions to send custom notifications.
Skip CKAN email sending if smtp server is not defined.
17 changes: 17 additions & 0 deletions ckan/config/config_declaration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1674,6 +1674,23 @@ groups:
example: ckan-errors@example.com
description: This controls from which email the error messages will come from.

- annotation: Notifier settings
options:
- key: ckan.notifier.notify_all
type: bool
default: false
example: "true"
description: |
Send notification to all plugins if True, otherwise only to the
first plugin implementing ``INotifier.notify_recipient``.

- key: ckan.notifier.always_send_email
type: bool
default: true
example: "false"
description: |
Send email notifications to users even if they have already been
notified by plugins.

- annotation: Background Job Settings
options:
Expand Down
26 changes: 22 additions & 4 deletions ckan/lib/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@


import ckan
from ckan import plugins
import ckan.model as model
import ckan.lib.helpers as h
from ckan.lib.base import render
Expand Down Expand Up @@ -180,10 +181,27 @@ def mail_recipient(recipient_name: str,
'''
site_title = config.get('ckan.site_title')
site_url = config.get('ckan.site_url')
return _mail_recipient(
recipient_name, recipient_email,
site_title, site_url, subject, body,
body_html=body_html, headers=headers, attachments=attachments)
notify_all = config.get('ckan.notifier.notify_all')
notification_sent = False
for plugin in plugins.PluginImplementations(plugins.INotifier):
# Allow extensions to use other notification methods
notification_sent = plugin.notify_recipient(
recipient_name, recipient_email, subject,
body, body_html, headers, attachments
)
if notification_sent and not notify_all:
break

# send an email ONLY if we have smtp settings available
always_send_email = config.get('ckan.notifier.always_send_email')
if notification_sent and not always_send_email:
return
if config.get('smtp.server'):
_mail_recipient(
recipient_name, recipient_email,
site_title, site_url, subject, body,
body_html, headers, attachments
)


def mail_user(recipient: model.User,
Expand Down
81 changes: 79 additions & 2 deletions ckan/plugins/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
from __future__ import annotations

from typing import (
Any, Callable, Iterable, Mapping, Optional, Sequence,
TYPE_CHECKING, Union,
Any, Callable, IO, Iterable, Mapping, Optional, Sequence,
TYPE_CHECKING, Tuple, Union
)

from flask.blueprints import Blueprint
Expand All @@ -31,6 +31,14 @@
from ckan.config.declaration import Declaration, Key


AttachmentWithType = Union[
Tuple[str, IO[str], str],
Tuple[str, IO[bytes], str]
]
AttachmentWithoutType = Union[Tuple[str, IO[str]], Tuple[str, IO[bytes]]]
Attachment = Union[AttachmentWithType, AttachmentWithoutType]


__all__ = [
u'Interface',
u'IMiddleware',
Expand Down Expand Up @@ -63,6 +71,7 @@
u'IApiToken',
u'IClick',
u'ISignal',
u'INotifier',
]


Expand Down Expand Up @@ -2180,3 +2189,71 @@ def get_signal_subscriptions(self):

"""
return {}


class INotifier(Interface):
"""
Allow plugins to add custom notification mechanisms. CKAN by default uses
email notifications. This interface allows plugins to add custom
notification mechanisms.

You can skip CKAN email notifications with
``ckan.notifier.always_send_email=False`` (defaults: ``True``)
in your configuration file.
You can allow multiple plugins to send notifications by setting
``ckan.notifier.notify_all=True`` (defaults: ``False``).
If ``False``, only the first plugin
that returns ``True`` for ``notify_recipient`` will send the notification.

"""

def notify_recipient(
self,
recipient_name: str,
recipient_email: str,
subject: str,
body: str,
body_html: Optional[str] = None,
headers: Optional[dict[str, Any]] = None,
attachments: Optional[Iterable[Attachment]] = None
) -> bool:
'''Sends an notification to a user.

.. note:: This custom notification could replace the default
email mechanism.

:param recipient_name: the name of the recipient
:type recipient: string
:param recipient_email: the email address of the recipient
:type recipient: string

:param subject: the notification subject
:type subject: string
:param body: the notification body, in plain text
:type body: string
:param body_html: the notification body, in html format (optional)
:type body_html: string
:headers: extra headers to add to notification, in the form
{'Header name': 'Header value'}
:type: dict
:attachments: a list of tuples containing file attachments to add to
the notification.
Tuples should contain the file name and a file-like
object pointing to the file contents::

[
('some_report.csv', file_object),
]

Optionally, you can add a third element to the tuple containing the
media type::

[
('some_report.csv', file_object, 'text/csv'),
]
:type: list

:returns: True if the notification was sent successfully,
False otherwise
'''
return False
Empty file.
63 changes: 63 additions & 0 deletions ckanext/example_inotifier/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
Two test notifiers
"""
from __future__ import annotations

import logging
from typing import Any, Optional
from ckan import plugins


log = logging.getLogger(__name__)


class ExampleINotifier1Plugin(plugins.SingletonPlugin):
plugins.implements(plugins.INotifier, inherit=True)

def notify_recipient(
self,
recipient_name: str,
recipient_email: str,
subject: str,
body: str,
body_html: Optional[str] = None,
headers: Optional[dict[str, Any]] = None,
attachments: Any = None
) -> bool:

msg = (
f'Notification [1] example for {recipient_name} '
f'<{recipient_email}> '
f'with subject {subject} and body {body} or {body_html} '
f'and headers {headers} and attachments {attachments}'
)
log.info(msg)

# Here you would send an email to the recipient
return True


class ExampleINotifier2Plugin(plugins.SingletonPlugin):
plugins.implements(plugins.INotifier, inherit=True)

def notify_recipient(
self,
recipient_name: str,
recipient_email: str,
subject: str,
body: str,
body_html: Optional[str] = None,
headers: Optional[dict[str, Any]] = None,
attachments: Any = None
) -> bool:

msg = (
f'Notification [2] example for {recipient_name} '
f'<{recipient_email}> '
f'with subject {subject} and body {body} or {body_html} '
f'and headers {headers} and attachments {attachments}'
)
log.info(msg)

# Here you would send an email to the recipient
return True
Empty file.
62 changes: 62 additions & 0 deletions ckanext/example_inotifier/test/test_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import pytest
from unittest.mock import patch
from ckan.lib.mailer import mail_recipient


test_email = {
"recipient_name": "Bob",
"recipient_email": "bob@example.com",
"subject": "Meeting",
"body": "The meeting is cancelled",
"headers": {"header1": "value1"},
}


@pytest.mark.ckan_config(
"ckan.plugins", "example_inotifier1 example_inotifier2"
)
@pytest.mark.usefixtures("with_plugins", "non_clean_db")
class TestINotifier:

@pytest.mark.ckan_config("ckan.notifier.always_send_email", "true")
@pytest.mark.ckan_config("ckan.notifier.notify_all", "true")
@patch("ckan.lib.mailer._mail_recipient")
@patch("ckanext.example_inotifier.plugin.ExampleINotifier1Plugin.notify_recipient")
@patch("ckanext.example_inotifier.plugin.ExampleINotifier2Plugin.notify_recipient")
def test_inotifier_full(self, nr2, nr1, mr):
mail_recipient(**test_email)
mr.assert_called()
assert mr.call_args_list[0][0][0] == test_email["recipient_name"]

nr1.assert_called()
assert nr1.call_args_list[0][0][0] == test_email["recipient_name"]
nr2.assert_called()
assert nr2.call_args_list[0][0][0] == test_email["recipient_name"]

@pytest.mark.ckan_config("ckan.notifier.always_send_email", "false")
@pytest.mark.ckan_config("ckan.notifier.notify_all", "true")
@patch("ckan.lib.mailer._mail_recipient")
@patch("ckanext.example_inotifier.plugin.ExampleINotifier1Plugin.notify_recipient")
@patch("ckanext.example_inotifier.plugin.ExampleINotifier2Plugin.notify_recipient")
def test_mail_inotifier_no_mail(self, nr2, nr1, mr):
mail_recipient(**test_email)
# We do not send email because we send custom notifications
mr.assert_not_called()
nr1.assert_called()
assert nr1.call_args_list[0][0][0] == test_email["recipient_name"]
nr2.assert_called()
assert nr2.call_args_list[0][0][0] == test_email["recipient_name"]

@pytest.mark.ckan_config("ckan.notifier.always_send_email", "false")
@pytest.mark.ckan_config("ckan.notifier.notify_all", "false")
@patch("ckan.lib.mailer._mail_recipient")
@patch("ckanext.example_inotifier.plugin.ExampleINotifier1Plugin.notify_recipient")
@patch("ckanext.example_inotifier.plugin.ExampleINotifier2Plugin.notify_recipient")
def test_inotifier_no_mail_just_one(self, nr2, nr1, mr):
mail_recipient(**test_email)
# We do not send email because we send custom notifications
mr.assert_not_called()
nr1.assert_called()
assert nr1.call_args_list[0][0][0] == test_email["recipient_name"]
# We just send the first notification
nr2.assert_not_called()
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ ckan.plugins =
example_icolumntypes = ckanext.example_icolumntypes.plugin:ExampleIColumnTypesPlugin
example_icolumnconstraints = ckanext.example_icolumnconstraints.plugin:ExampleIColumnConstraintsPlugin
example_idatadictionaryform = ckanext.example_idatadictionaryform.plugin:ExampleIDataDictionaryFormPlugin
example_inotifier1 = ckanext.example_inotifier.plugin:ExampleINotifier1Plugin
example_inotifier2 = ckanext.example_inotifier.plugin:ExampleINotifier2Plugin

ckan.system_plugins =
synchronous_search = ckan.lib.search:SynchronousSearchPlugin
Expand Down