Skip to content

Commit

Permalink
Feat: Whatsapp notifications (#14)
Browse files Browse the repository at this point in the history
* First draft of whatsapp notifications refactor

* User form template update

* CKAN core requires timezone naive datetimes

* Test whatsapp notifications generated

* Send whatsapp messages

* Validate user plugin extras

* Validate phone number on keyup

* Test user extras validation

* Allow user to disable sms or whatsapp notifications when calling the action

* Rename action to send_phone_notifications

* Test messages comply with whatsapp templates
  • Loading branch information
jonathansberry authored Dec 17, 2024
1 parent bb29619 commit 127dce4
Show file tree
Hide file tree
Showing 15 changed files with 569 additions and 400 deletions.
64 changes: 0 additions & 64 deletions ckanext/dataset_subscriptions/actions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,64 +0,0 @@
import ckan.plugins.toolkit as toolkit
import ckan.logic as logic
from ckanext.activity.email_notifications import send_notification
import ckanext.dataset_subscriptions.helpers as helpers
import ckanext.activity.email_notifications as email_notifications
import ckan.model as model
import ckan.lib.base as base
from ckan.common import config
import unihandecode


@toolkit.chained_action
@toolkit.side_effect_free
def send_email_notifications(original_action, context, data_dict):
email_notifications._notifications_functions = [dms_notification_provider]
email_notifications.send_notification = latin_username_send_notification
return original_action(context, data_dict)


def latin_username_send_notification(user, email_dict):
# fix for AWS SES not supporting UTF8 encoding of recepient field
# https://docs.aws.amazon.com/cli/latest/reference/ses/send-email.html
user['display_name'] = unihandecode.unidecode(user['display_name'])
return send_notification(user, email_dict)


def dms_notification_provider(user_dict, since):
if not user_dict.get('activity_streams_email_notifications'):
return []
context = {'model': model, 'session': model.Session,
'user': user_dict['id']}
activity_list = logic.get_action('dashboard_activity_list')(context, {})
dataset_activity_list = [activity for activity in activity_list
if activity['user_id'] != user_dict['id']
and 'package' in activity['activity_type']]
# We want a notification per changed dataset, not a list of all changes
timestamp_sorted_activity_list = sorted(dataset_activity_list,
key=lambda item: item['timestamp'])
deduplicated_activity_list = list({item["object_id"]:
item for item in timestamp_sorted_activity_list}.values())
activity_list_with_dataset_name = helpers.add_dataset_details_to_activity_list(deduplicated_activity_list, context)
recent_activity_list = helpers.filter_out_old_activites(activity_list_with_dataset_name, since)
return dms_notifications_for_activities(recent_activity_list, user_dict)


def dms_notifications_for_activities(activities, user_dict):
if not activities:
return []
if not user_dict.get('activity_streams_email_notifications'):
return []
subject = toolkit.ungettext(
"{n} new notification from Department of HIV and AIDS Document Management System",
"{n} new notifications from Department of HIV and AIDS Document Management System",
len(activities)).format(
site_title=config.get('ckan.site_title'),
n=len(activities))
body = base.render(
'dataset-subscriptions_email_body.j2',
extra_vars={'activities': activities})
notifications = [{
'subject': subject,
'body': body
}]
return notifications
58 changes: 58 additions & 0 deletions ckanext/dataset_subscriptions/actions/email_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import ckan.plugins.toolkit as toolkit
import ckanext.dataset_subscriptions.helpers as helpers
from ckanext.activity.email_notifications import send_notification
import ckanext.activity.email_notifications as email_notifications
import unihandecode


@toolkit.chained_action
@toolkit.side_effect_free
def send_email_notifications(original_action, context, data_dict):
email_notifications._notifications_functions = [dms_notification_provider]
email_notifications.send_notification = latin_username_send_notification
return original_action(context, data_dict)


def latin_username_send_notification(user, email_dict):
# fix for AWS SES not supporting UTF8 encoding of recepient field
# https://docs.aws.amazon.com/cli/latest/reference/ses/send-email.html
user['display_name'] = unihandecode.unidecode(user['display_name'])
return send_notification(user, email_dict)


def dms_notification_provider(user_dict, since):
if not user_dict.get('activity_streams_email_notifications'):
return []
activity_list = toolkit.get_action('dashboard_activity_list')({'user': user_dict['id']}, {})
dataset_activity_list = [activity for activity in activity_list
if activity['user_id'] != user_dict['id']
and 'package' in activity['activity_type']]
# We want a notification per changed dataset, not a list of all changes
timestamp_sorted_activity_list = sorted(dataset_activity_list,
key=lambda item: item['timestamp'])
deduplicated_activity_list = list({item["object_id"]:
item for item in timestamp_sorted_activity_list}.values())
activity_list_with_dataset_name = helpers.add_dataset_details_to_activity_list(deduplicated_activity_list)
recent_activity_list = helpers.filter_out_old_activites(activity_list_with_dataset_name, since)
return dms_notifications_for_activities(recent_activity_list, user_dict)


def dms_notifications_for_activities(activities, user_dict):
if not activities:
return []
if not user_dict.get('activity_streams_email_notifications'):
return []
subject = toolkit.ungettext(
"{n} new notification from {site_title}",
"{n} new notifications from {site_title}",
len(activities)).format(
site_title=toolkit.config.get('ckan.site_title'),
n=len(activities))
body = toolkit.render(
'dataset-subscriptions_email_body.j2',
extra_vars={'activities': activities})
notifications = [{
'subject': subject,
'body': body
}]
return notifications
139 changes: 139 additions & 0 deletions ckanext/dataset_subscriptions/actions/phone_notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import logging
from ckan.plugins import toolkit
from twilio.rest import Client
from twilio.base.exceptions import TwilioRestException
from datetime import timedelta, datetime
from ckanext.dataset_subscriptions import helpers


ACCOUNT_SID = toolkit.config.get('ckanext.dataset_subscriptions.twilio_account_sid')
AUTH_TOKEN = toolkit.config.get('ckanext.dataset_subscriptions.twilio_auth_token')
SMS_SENDER_NR = toolkit.config.get('ckanext.dataset_subscriptions.sms_sender_nr')
WHATSAPP_SENDER_NR = toolkit.config.get('ckanext.dataset_subscriptions.whatsapp_sender_nr')


client = Client(ACCOUNT_SID, AUTH_TOKEN)
logger = logging.getLogger(__name__)


def send_phone_notifications(context, data_dict):
"""
Sends SMS and Whatsapp notifications via the Twilio API. Both notification types
have been combined into a single action since they share a great deal of logic
which requires a loop through all site users.
:param send_sms: Send SMS messages (optional, default: ``True``)
:type send_sms: bool
:param send_whatsapp: Send Whatsapp messages (optional, default: ``True``)
:type send_whatsapp: bool
"""
message_sids = []
toolkit.check_access('send_email_notifications', context, data_dict)
users = toolkit.get_action('user_list')(
{'ignore_auth': True},
{'all_fields': True, 'include_plugin_extras': True}
)
for user in users:
if _twilio_notifications_enabled(user, data_dict):
recent_activities = _get_recent_activity_list(user, context)
if recent_activities:
if _sms_notifications_enabled(user, data_dict):
message_sids.append(_send_message(
_create_sms_message(recent_activities),
SMS_SENDER_NR,
user['phonenumber']
))
if _whatsapp_notifications_enabled(user, data_dict):
message_sids.append(_send_message(
_create_message_header(recent_activities),
f"whatsapp:{WHATSAPP_SENDER_NR}",
f"whatsapp:{user['phonenumber']}"
))
return message_sids


def _sms_notifications_enabled(user_dict, data_dict):
action_send_sms = toolkit.asbool(data_dict.get('send_sms', True))
user_enabled_sms = toolkit.asbool(user_dict.get("activity_streams_sms_notifications"))
if action_send_sms and user_enabled_sms and user_dict.get("phonenumber"):
return True
return False


def _whatsapp_notifications_enabled(user_dict, data_dict):
action_send_whatsapp = toolkit.asbool(data_dict.get('send_whatsapp', True))
user_enabled_whatsapp = toolkit.asbool(user_dict.get("activity_streams_whatsapp_notifications"))
if action_send_whatsapp and user_enabled_whatsapp and user_dict.get("phonenumber"):
return True
return False


def _twilio_notifications_enabled(user_dict, data_dict):
if _sms_notifications_enabled(user_dict, data_dict) or \
_whatsapp_notifications_enabled(user_dict, data_dict):
return True
return False


def _twilio_notification_time_delta_utc():
since_hours = toolkit.config.get('ckanext.dataset_subscriptions.sms_notifications_hours_since', 1)
since_delta = timedelta(hours=int(since_hours))
since_datetime = (datetime.utcnow() - since_delta)
return since_datetime


def _get_recent_activity_list(user_dict, context):
# Only raise notifications for activities since last message, or last view of the dashboard
dashboard_last_viewed = (context['model'].Dashboard.get(user_dict['id']).activity_stream_last_viewed)
since = max(_twilio_notification_time_delta_utc(), dashboard_last_viewed)
# Get activities for only changes to datasets, within the desired period, not made by notifiee
activity_list = toolkit.get_action('dashboard_activity_list')({'user': user_dict['id']}, {})
dataset_activity_list = [activity for activity in activity_list
if activity['user_id'] != user_dict['id']
and 'package' in activity['activity_type']]
# We want the latest notification per changed dataset, not all changes
timestamp_sorted_activity_list = sorted(dataset_activity_list, key=lambda item: item['timestamp'])
deduplicated_activity_list = list({
item["object_id"]: item for item in timestamp_sorted_activity_list
}.values())
activity_list_with_dataset_name = helpers.add_dataset_details_to_activity_list(deduplicated_activity_list)
recent_activity_list = helpers.filter_out_old_activites(activity_list_with_dataset_name, since)
return recent_activity_list


def _create_sms_message(activities):
nr_of_datasets_to_display = toolkit.config.get(
'ckanext.dataset_subscriptions.sms_nr_of_datasets_to_display', 2
)
header = _create_message_header(activities)
return toolkit.render(
'dataset-subscriptions_sms_body.j2',
extra_vars={
'activities': activities,
'header': header,
'nr_of_datasets_to_display': nr_of_datasets_to_display
}
)


def _create_message_header(activities):
return toolkit.ungettext(
"{n} dataset that you are following has recently been updated in {site_title}",
"{n} datasets that you are following have recently been updated in {site_title}",
len(activities)).format(
site_title=toolkit.config.get('ckan.site_title'),
n=len(activities)
)


def _send_message(message_body, sender, phonenumber):
try:
message = client.messages.create(
from_=sender,
body=message_body,
to=phonenumber
)
except TwilioRestException:
logger.exception(f"Failed to send sms message to {phonenumber}", exc_info=True)
return
return message.sid
Loading

0 comments on commit 127dce4

Please sign in to comment.