diff --git a/ckanext/dataset_subscriptions/actions/__init__.py b/ckanext/dataset_subscriptions/actions/__init__.py index 66d7167..e69de29 100644 --- a/ckanext/dataset_subscriptions/actions/__init__.py +++ b/ckanext/dataset_subscriptions/actions/__init__.py @@ -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 diff --git a/ckanext/dataset_subscriptions/actions/email_notifications.py b/ckanext/dataset_subscriptions/actions/email_notifications.py new file mode 100644 index 0000000..43d20ae --- /dev/null +++ b/ckanext/dataset_subscriptions/actions/email_notifications.py @@ -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 diff --git a/ckanext/dataset_subscriptions/actions/phone_notifications.py b/ckanext/dataset_subscriptions/actions/phone_notifications.py new file mode 100644 index 0000000..d5e2f65 --- /dev/null +++ b/ckanext/dataset_subscriptions/actions/phone_notifications.py @@ -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 diff --git a/ckanext/dataset_subscriptions/actions/sms_notifications.py b/ckanext/dataset_subscriptions/actions/sms_notifications.py deleted file mode 100644 index 55c98b9..0000000 --- a/ckanext/dataset_subscriptions/actions/sms_notifications.py +++ /dev/null @@ -1,200 +0,0 @@ -import copy -import logging -from ckan.plugins import toolkit -import ckan.lib.base as base -import ckan.logic as logic -import ckan.model as model -from twilio.rest import Client -from twilio.base.exceptions import TwilioRestException -from datetime import timedelta, datetime -from ckanext.dataset_subscriptions import helpers -from ckan.common import request - - -logger = logging.getLogger(__name__) - - -ACCOUNT_SID = toolkit.config.get('ckanext.dataset_subscriptions.twilio_account_sid') -AUTH_TOKEN = toolkit.config.get('ckanext.dataset_subscriptions.twilio_auth_token') -SENDER_NR = toolkit.config.get('ckanext.dataset_subscriptions.sms_sender_nr') -client = Client(ACCOUNT_SID, AUTH_TOKEN) - - -CUSTOM_FIELDS = [ - {'name': 'phonenumber', 'default': ''}, - {'name': 'activity_streams_sms_notifications', 'default': False}, -] - -DATASET_SUBSCRIPTIONS = 'dataset_subscriptions' - - -@toolkit.chained_action -@toolkit.side_effect_free -def user_show(original_action, context, data_dict): - user = original_action(context, data_dict) - user_obj = _get_user_obj(context) - - plugin_extras = _init_plugin_extras(user_obj.plugin_extras) - dataset_subscriptions_extras = _validate_plugin_extras(plugin_extras[DATASET_SUBSCRIPTIONS]) - for field in CUSTOM_FIELDS: - user[field['name']] = dataset_subscriptions_extras[field['name']] - - return user - - -@toolkit.chained_action -def user_create(original_action, context, data_dict): - for field in CUSTOM_FIELDS: - if not field['name'] in data_dict: - data_dict[field['name']] = field['default'] - - user_dict = original_action(context, data_dict) - user_obj = _get_user_obj(context) - - plugin_extras = _init_plugin_extras(user_obj.plugin_extras) - dataset_subscriptions_extras = plugin_extras[DATASET_SUBSCRIPTIONS] - for field in CUSTOM_FIELDS: - dataset_subscriptions_extras[field['name']] = data_dict[field['name']] - user_obj.plugin_extras = plugin_extras - model_ = context.get('model', model) - model_.Session.commit() - - for field in CUSTOM_FIELDS: - user_dict[field['name']] = dataset_subscriptions_extras[field['name']] - return user_dict - - -@toolkit.chained_action -def user_update(original_action, context, data_dict): - for field in CUSTOM_FIELDS: - if not field['name'] in data_dict: - data_dict[field['name']] = field['default'] - - user_dict = original_action(context, data_dict) - user_obj = _get_user_obj(context) - - plugin_extras = _init_plugin_extras(user_obj.plugin_extras) - dataset_subscriptions_extras = plugin_extras[DATASET_SUBSCRIPTIONS] - for field in CUSTOM_FIELDS: - dataset_subscriptions_extras[field['name']] = data_dict[field['name']] - user_obj.plugin_extras = plugin_extras - model_ = context.get('model', model) - model_.Session.commit() - - for field in CUSTOM_FIELDS: - user_dict[field['name']] = dataset_subscriptions_extras[field['name']] - return user_dict - - -def _init_plugin_extras(plugin_extras): - out_dict = copy.deepcopy(plugin_extras) - if not out_dict: - out_dict = {} - if DATASET_SUBSCRIPTIONS not in out_dict: - out_dict[DATASET_SUBSCRIPTIONS] = {} - return out_dict - - -def _get_user_obj(context): - if 'user_obj' in context: - return context['user_obj'] - user = context.get('user') - model_ = context.get('model', model) - user_obj = model_.User.get(user) - if not user_obj: - raise toolkit.ObjectNotFound("User not found") - return user_obj - - -def _validate_plugin_extras(extras): - if not extras: - extras = {} - out_dict = {} - for field in CUSTOM_FIELDS: - out_dict[field['name']] = extras.get(field['name'], field['default']) - return out_dict - - -def sms_notifications_enabled(user_dict): - if user_dict.get("activity_streams_sms_notifications") and user_dict.get("phonenumber"): - return True - return False - - -def get_phonenumber(user_dict): - phonenumber = user_dict["phonenumber"] - return phonenumber - - -def send_sms_notifications(context, data_dict): - toolkit.check_access('send_email_notifications', context, data_dict) - context = {'model': model, 'session': model.Session, 'ignore_auth': True} - users = logic.get_action('user_list')(context, {'all_fields': True}) - notification_sids = [] - for user in users: - user = logic.get_action('user_show')(context, {'id': user['id'], - 'include_plugin_extras': False}) - if sms_notifications_enabled(user): - get_phonenumber(user) - notification_sids.append(prepare_sms_notifications(user)) - return notification_sids - - -def _sms_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 prepare_sms_notifications(user): - sms_notifications_since = _sms_notification_time_delta_utc() - activity_stream_last_viewed = ( - model.Dashboard.get(user['id']).activity_stream_last_viewed) - since = max(sms_notifications_since, activity_stream_last_viewed) - return dms_sms_notification_provider(user, since) - - -def dms_sms_notification_provider(user_dict, since): - 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) - if recent_activity_list: - user_phonenumber = get_phonenumber(user_dict) - return send_sms_notification(recent_activity_list, user_phonenumber) - - -def send_sms_notification(activities, phonenumber): - from_nr = SENDER_NR - to_nr = phonenumber - nr_of_datasets_to_display = toolkit.config.get('ckanext.dataset_subscriptions.sms_nr_of_datasets_to_display', 2) - header = toolkit.ungettext( - "{n} dataset have recently been updated in {site_title}", - "{n} datasets have recently been updated in {site_title}", - len(activities)).format( - site_title=toolkit.config.get('ckan.site_title'), - n=len(activities)) - message_body = base.render( - 'dataset-subscriptions_sms_body.j2', - extra_vars={'activities': activities, 'header': header, 'nr_of_datasets_to_display': nr_of_datasets_to_display}) - try: - message = client.messages.create( - from_=from_nr, - body=message_body, - to=to_nr - ) - except TwilioRestException: - logger.exception("Failed to send sms message", exc_info=True) - return - return message.sid diff --git a/ckanext/dataset_subscriptions/actions/user.py b/ckanext/dataset_subscriptions/actions/user.py new file mode 100644 index 0000000..a1af30b --- /dev/null +++ b/ckanext/dataset_subscriptions/actions/user.py @@ -0,0 +1,155 @@ +import copy +import logging +from ckan.plugins import toolkit +import ckan.model as model +from twilio.rest import Client +from twilio.base.exceptions import TwilioRestException + +ACCOUNT_SID = toolkit.config.get('ckanext.dataset_subscriptions.twilio_account_sid') +AUTH_TOKEN = toolkit.config.get('ckanext.dataset_subscriptions.twilio_auth_token') + + +logger = logging.getLogger(__name__) +client = Client(ACCOUNT_SID, AUTH_TOKEN) + + +CUSTOM_FIELDS = [ + {'name': 'phonenumber', 'default': ''}, + {'name': 'activity_streams_sms_notifications', 'default': False}, + {'name': 'activity_streams_whatsapp_notifications', 'default': False}, +] + +DATASET_SUBSCRIPTIONS = 'dataset_subscriptions' + + +@toolkit.chained_action +@toolkit.side_effect_free +def user_show(original_action, context, data_dict): + user = original_action(context, data_dict) + user_obj = _get_user_obj(context) + + plugin_extras = _setup_plugin_extras(user_obj.plugin_extras) + dataset_subscriptions_extras = _init_plugin_extras(plugin_extras[DATASET_SUBSCRIPTIONS]) + for field in CUSTOM_FIELDS: + user[field['name']] = dataset_subscriptions_extras[field['name']] + + return user + + +@toolkit.chained_action +def user_create(original_action, context, data_dict): + for field in CUSTOM_FIELDS: + if not field['name'] in data_dict: + data_dict[field['name']] = field['default'] + + user_dict = original_action(context, data_dict) + user_obj = _get_user_obj(context) + + plugin_extras = _setup_plugin_extras(user_obj.plugin_extras) + dataset_subscriptions_extras = plugin_extras[DATASET_SUBSCRIPTIONS] + for field in CUSTOM_FIELDS: + dataset_subscriptions_extras[field['name']] = data_dict[field['name']] + _validate_plugin_extras(dataset_subscriptions_extras) + user_obj.plugin_extras = plugin_extras + model_ = context.get('model', model) + model_.Session.commit() + + for field in CUSTOM_FIELDS: + user_dict[field['name']] = dataset_subscriptions_extras[field['name']] + return user_dict + + +@toolkit.chained_action +def user_update(original_action, context, data_dict): + for field in CUSTOM_FIELDS: + if not field['name'] in data_dict: + data_dict[field['name']] = field['default'] + + user_dict = original_action(context, data_dict) + user_obj = _get_user_obj(context) + + plugin_extras = _setup_plugin_extras(user_obj.plugin_extras) + dataset_subscriptions_extras = plugin_extras[DATASET_SUBSCRIPTIONS] + for field in CUSTOM_FIELDS: + dataset_subscriptions_extras[field['name']] = data_dict[field['name']] + _validate_plugin_extras(dataset_subscriptions_extras) + user_obj.plugin_extras = plugin_extras + model_ = context.get('model', model) + model_.Session.commit() + + for field in CUSTOM_FIELDS: + user_dict[field['name']] = dataset_subscriptions_extras[field['name']] + return user_dict + + +@toolkit.chained_action +@toolkit.side_effect_free +def user_list(original_action, context, data_dict): + user_list = original_action(context, data_dict) + include_plugin_extras = toolkit.asbool(data_dict.get('include_plugin_extras', False)) + all_fields = toolkit.asbool(data_dict.get('all_fields', True)) + if all_fields and include_plugin_extras: + for index, user in enumerate(user_list): + user_list[index] = toolkit.get_action('user_show')(context, {'id': user['name']}) + return user_list + + +def _setup_plugin_extras(plugin_extras): + out_dict = copy.deepcopy(plugin_extras) + if not out_dict: + out_dict = {} + if DATASET_SUBSCRIPTIONS not in out_dict: + out_dict[DATASET_SUBSCRIPTIONS] = {} + return out_dict + + +def _get_user_obj(context): + if 'user_obj' in context: + return context['user_obj'] + user = context.get('user') + model_ = context.get('model', model) + user_obj = model_.User.get(user) + if not user_obj: + raise toolkit.ObjectNotFound("User not found") + return user_obj + + +def _init_plugin_extras(extras): + if not extras: + extras = {} + out_dict = {} + for field in CUSTOM_FIELDS: + out_dict[field['name']] = extras.get(field['name'], field['default']) + return out_dict + + +def _validate_plugin_extras(extras): + errors = {} + if extras.get('phonenumber'): + try: + client.lookups.phone_numbers(extras['phonenumber']).fetch(type=['carrier']) + except TwilioRestException as e: + if e.status == 404: + errors['phonenumber'] = [toolkit._(f'Invalid phonenumber: {extras["phonenumber"]}')] + else: + logger.exception( + f"Failed to reach Twilio API to verify phonenumber {extras['phonenumber']}", + exc_info=True + ) + except Exception: + logger.exception( + f"Failed to reach Twilio API to verify phonenumber {extras['phonenumber']}", + exc_info=True + ) + if toolkit.asbool(extras.get('activity_streams_sms_notifications')): + if not extras.get('phonenumber'): + errors['activity_streams_sms_notifications'] = [ + toolkit._('No phone number given') + ] + if toolkit.asbool(extras.get('activity_streams_whatsapp_notifications')): + if not extras.get('phonenumber'): + errors['activity_streams_whatsapp_notifications'] = [ + toolkit._('No phone number given') + ] + if errors: + raise toolkit.ValidationError(errors) diff --git a/ckanext/dataset_subscriptions/assets/js/processPhoneNumber.js b/ckanext/dataset_subscriptions/assets/js/processPhoneNumber.js index 4eb6c24..1fbfea9 100644 --- a/ckanext/dataset_subscriptions/assets/js/processPhoneNumber.js +++ b/ckanext/dataset_subscriptions/assets/js/processPhoneNumber.js @@ -1,21 +1,26 @@ const phoneInputField = document.querySelector("#field-phonenumber"); const phoneInput = window.intlTelInput(phoneInputField, { preferredCountries: ["mw"], + formatOnDisplay: false, utilsScript: "https://cdnjs.cloudflare.com/ajax/libs/intl-tel-input/17.0.8/js/utils.js", }); const error = document.querySelector(".phonenumber.alert-error"); -function process(event) { +function process() { const phoneNumber = phoneInput.getNumber(); const emptyNumber = phoneNumber === ""; if (emptyNumber || phoneInput.isValidNumber()) { + error.innerHTML = "" + error.style.display = "none"; phoneInputField.value = phoneNumber; + } else { error.style.display = ""; error.innerHTML = `Invalid phone number. Please verify your input.`; - event.preventDefault(); } } + +phoneInputField.onkeyup = process; diff --git a/ckanext/dataset_subscriptions/helpers.py b/ckanext/dataset_subscriptions/helpers.py index 3497572..1d1c519 100644 --- a/ckanext/dataset_subscriptions/helpers.py +++ b/ckanext/dataset_subscriptions/helpers.py @@ -6,11 +6,11 @@ logger = logging.getLogger(__name__) -def add_dataset_details_to_activity_list(activity_list, context): +def add_dataset_details_to_activity_list(activity_list): for index, activity in enumerate(activity_list): object_id = activity['object_id'] try: - dataset = toolkit.get_action('package_show')(context, {'id': object_id}) + dataset = toolkit.get_action('package_show')({}, {'id': object_id}) except Exception: logger.exception(f"Unable to get details of package: {object_id}") return [] diff --git a/ckanext/dataset_subscriptions/plugin.py b/ckanext/dataset_subscriptions/plugin.py index fbdb4f8..cb92b60 100644 --- a/ckanext/dataset_subscriptions/plugin.py +++ b/ckanext/dataset_subscriptions/plugin.py @@ -1,7 +1,6 @@ import ckan.plugins as plugins import ckan.plugins.toolkit as toolkit -from ckanext.dataset_subscriptions import actions -from ckanext.dataset_subscriptions.actions import sms_notifications +from ckanext.dataset_subscriptions.actions import email_notifications, phone_notifications, user class DatasetSubscriptionsPlugin(plugins.SingletonPlugin): @@ -17,9 +16,10 @@ def update_config(self, config_): # IActions def get_actions(self): return { - 'send_email_notifications': actions.send_email_notifications, - 'send_sms_notifications': sms_notifications.send_sms_notifications, - 'user_create': sms_notifications.user_create, - 'user_update': sms_notifications.user_update, - 'user_show': sms_notifications.user_show + 'send_email_notifications': email_notifications.send_email_notifications, + 'send_phone_notifications': phone_notifications.send_phone_notifications, + 'user_create': user.user_create, + 'user_update': user.user_update, + 'user_show': user.user_show, + 'user_list': user.user_list } diff --git a/ckanext/dataset_subscriptions/templates/user/edit_user_form.html b/ckanext/dataset_subscriptions/templates/user/edit_user_form.html index c635b94..0c63b97 100644 --- a/ckanext/dataset_subscriptions/templates/user/edit_user_form.html +++ b/ckanext/dataset_subscriptions/templates/user/edit_user_form.html @@ -3,10 +3,14 @@ {% block extra_fields %} {% asset "ckanext-dataset-subscriptions/user-edit-js" %} {% asset "ckanext-dataset-subscriptions/user-edit-css" %} - {{ form.input('phonenumber', label=_('Phone number'), id='field-phonenumber', type='tel', value=data.phonenumber, error=errors.phonenumber, placeholder=_('eg. +44 123123123'), classes=['control-medium'], is_required=false) }} - {% call form.checkbox('activity_streams_sms_notifications', label=_('Subscribe to notification SMS text messages'), id='field-activity-streams-sms-notifications', value=True, checked=data.activity_streams_sms_notifications) %} - {% set helper_text = _("You will receive SMS notifications from {site_title} when you have new activities on your dashboard."|string) %} + + {% call form.checkbox('activity_streams_sms_notifications', label=_('Subscribe to recieve SMS notifications'), id='field-activity-streams-sms-notifications', value=True, checked=data.activity_streams_sms_notifications) %} + {% set helper_text = _("You will receive SMS notifications from {site_title} when datasets you are following have been updated."|string) %} + {{ form.info(helper_text.format(site_title=g.site_title), classes=['info-help-tight']) }} + {% endcall %} + {% call form.checkbox('activity_streams_whatsapp_notifications', label=_('Subscribe to recieve Whatsapp notifications'), id='field-activity-streams-whatsapp-notifications', value=True, checked=data.activity_streams_whatsapp_notifications) %} + {% set helper_text = _("You will receive Whatsapp notifications from {site_title} when datasets you are following have been updated."|string) %} {{ form.info(helper_text.format(site_title=g.site_title), classes=['info-help-tight']) }} {% endcall %} {% asset "ckanext-dataset-subscriptions/phone-number-js" %} diff --git a/ckanext/dataset_subscriptions/tests/test_actions.py b/ckanext/dataset_subscriptions/tests/actions/test_email_notifications.py similarity index 92% rename from ckanext/dataset_subscriptions/tests/test_actions.py rename to ckanext/dataset_subscriptions/tests/actions/test_email_notifications.py index 419d2d5..9d08bc0 100644 --- a/ckanext/dataset_subscriptions/tests/test_actions.py +++ b/ckanext/dataset_subscriptions/tests/actions/test_email_notifications.py @@ -1,19 +1,20 @@ from ckan.tests import factories as ckan_factories -from ckanext.dataset_subscriptions.tests import factories +from ckanext.dataset_subscriptions.tests import factories as plugin_factories import ckanext.activity.email_notifications as email_notifications import ckan.tests.helpers as helpers import pytest import datetime - @pytest.mark.ckan_config('ckan.plugins') @pytest.mark.usefixtures("with_plugins") @pytest.mark.usefixtures("clean_db") def create_user_with_resources(with_activity): - subscribed_user = factories.User(name='user1', - activity_streams_email_notifications=True) - active_user = factories.User(name='user2') + subscribed_user = plugin_factories.User( + name='user1', + activity_streams_email_notifications=True + ) + active_user = plugin_factories.User(name='user2') organization = ckan_factories.Organization( users=[ {'name': subscribed_user['name'], 'capacity': 'member'}, diff --git a/ckanext/dataset_subscriptions/tests/actions/test_phone_notifications.py b/ckanext/dataset_subscriptions/tests/actions/test_phone_notifications.py new file mode 100644 index 0000000..af80cf2 --- /dev/null +++ b/ckanext/dataset_subscriptions/tests/actions/test_phone_notifications.py @@ -0,0 +1,97 @@ +import pytest +from ckan.tests import helpers +from ckan.tests import factories as ckan_factories +from ckanext.dataset_subscriptions.tests import factories +from ckanext.dataset_subscriptions.actions import phone_notifications +from unittest import mock +import re + + +# Whatsapp messages must conform to preapproved templates created in Twilio. +# Copy templates here as a regex replacing placeholders e.g. {{1}} with (.*) +WHATSAPP_MESSAGE_TEMPLATES = [ + r"(.*) datasets that you are following have recently been updated in (.*)", + r"(.*) dataset that you are following has recently been updated in (.*)" +] + + +def _matches_whatsapp_template(message): + for template in WHATSAPP_MESSAGE_TEMPLATES: + if re.compile(template).search(message): + return True + return False + + +@pytest.mark.ckan_config('ckan.plugins') +def create_user_with_resources(with_activity, sms_notifications_enabled, whatsapp_notifications_enabled): + subscribed_user = factories.User( + name='user1', + activity_streams_sms_notifications=sms_notifications_enabled, + activity_streams_whatsapp_notifications=whatsapp_notifications_enabled, + phonenumber="+1234" + ) + active_user = factories.User(name='user2') + organization = ckan_factories.Organization( + users=[ + {'name': subscribed_user['name'], 'capacity': 'member'}, + {'name': active_user['name'], 'capacity': 'editor'} + ] + ) + if with_activity: + dataset = ckan_factories.Dataset( + owner_org=organization['id'], + type='test-schema', + user=active_user + ) + helpers.call_action( + "follow_dataset", context={"user": subscribed_user["name"]}, + **dataset + ) + ckan_factories.Resource( + package_id=dataset['id'], + user=active_user + ) + return subscribed_user + + +@pytest.mark.usefixtures("with_plugins", "clean_db") +class TestPhoneNotifications(): + + @pytest.mark.parametrize("notifications_enabled", [(False), (True)]) + def test_sms_notifications_disabled_enabled(self, notifications_enabled): + user = create_user_with_resources(True, notifications_enabled, False) + notifications = phone_notifications._sms_notifications_enabled(user, {}) + assert notifications == notifications_enabled + + @pytest.mark.usefixtures("with_request_context") + @mock.patch('ckanext.dataset_subscriptions.actions.phone_notifications.client.messages.create') + def test_if_sms_notifications_are_generated(self, create_message_mock, sysadmin_context): + create_user_with_resources(True, True, False) + expected_sid = 'SM87105da94bff44b999e4e6eb90d8eb6a' + create_message_mock.return_value.sid = expected_sid + sid = helpers.call_action("send_phone_notifications") + assert create_message_mock.called is True + assert sid[0] == expected_sid + + @pytest.mark.usefixtures("with_request_context") + @mock.patch('ckanext.dataset_subscriptions.actions.phone_notifications.client.messages.create') + def test_if_whatsapp_notifications_are_generated(self, create_message_mock, sysadmin_context): + create_user_with_resources(True, False, True) + expected_sid = 'SM87105da94bff44b999e4e6eb90d8eb6a' + create_message_mock.return_value.sid = expected_sid + sid = helpers.call_action("send_phone_notifications") + assert create_message_mock.called is True + assert sid[0] == expected_sid + call_args = dict(create_message_mock.call_args.kwargs.items()) + assert 'whatsapp:+' in call_args['to'] + assert 'whatsapp:+' in call_args['from_'] + assert _matches_whatsapp_template(call_args['body']) + + @pytest.mark.parametrize('recent_activities', [[1], [1, 2, 3, 4]]) + def test_whatsapp_message_complies_with_templates(self, recent_activities): + """ + Whatsapp messages must comply with a template preapproved through twilio + https://www.twilio.com/docs/whatsapp/tutorial/send-whatsapp-notification-messages-templates + """ + header = phone_notifications._create_message_header(recent_activities) + assert _matches_whatsapp_template(header) diff --git a/ckanext/dataset_subscriptions/tests/actions/test_sms_notifications.py b/ckanext/dataset_subscriptions/tests/actions/test_sms_notifications.py deleted file mode 100644 index 849ebdf..0000000 --- a/ckanext/dataset_subscriptions/tests/actions/test_sms_notifications.py +++ /dev/null @@ -1,116 +0,0 @@ -import pytest -from ckan.tests import helpers -from ckan.tests import factories as ckan_factories -from ckanext.dataset_subscriptions.tests import factories -from ckanext.dataset_subscriptions.actions import sms_notifications -from unittest import mock - - -@pytest.mark.usefixtures("clean_db") -@pytest.mark.usefixtures("with_plugins") -def test_user_create_supports_plugin_extras(sysadmin_context): - user_dict = { - "name": "test_user_001", - "fullname": "Mr. Test User", - "password": "fjelltopp", - "display_name": "Mr. Test User", - "email": "test_user_001@ckan.org", - "phonenumber": 123, - "activity_streams_sms_notifications": True - } - - created_user = helpers.call_action('user_create', context=sysadmin_context, **user_dict) - - for key in ["phonenumber", "activity_streams_sms_notifications"]: - assert created_user[key] == user_dict[key] - - -@pytest.mark.usefixtures("clean_db") -@pytest.mark.usefixtures("with_plugins") -def test_user_update_supports_plugin_extras(sysadmin_context): - user = factories.User() - user_dict = {**user, **{ - "phonenumber": 123, - "activity_streams_sms_notifications": True - } - } - helpers.call_action('user_update', **user_dict) - updated_user = helpers.call_action('user_show', context=sysadmin_context, include_plugin_extras=True, **user_dict) - - for key in ["phonenumber", "activity_streams_sms_notifications"]: - assert updated_user[key] == user_dict[key] - - -@pytest.fixture -def sysadmin_context(): - sysadmin = ckan_factories.Sysadmin() - # helpers.call_action sets 'ignore_auth' to True by default - context = {'user': sysadmin['name'], 'ignore_auth': False} - return context - - -@pytest.mark.ckan_config('ckan.plugins') -@pytest.mark.usefixtures("with_plugins") -@pytest.mark.usefixtures("clean_db") -def create_user_with_resources(with_activity, with_notifications_enabled): - if with_notifications_enabled: - notifications_enabled = True - else: - notifications_enabled = False - subscribed_user = factories.User(name='user1', - activity_streams_sms_notifications=notifications_enabled, - phonenumber="+1234") - active_user = factories.User(name='user2') - organization = ckan_factories.Organization( - users=[ - {'name': subscribed_user['name'], 'capacity': 'member'}, - {'name': active_user['name'], 'capacity': 'editor'} - ] - ) - if with_activity: - dataset = ckan_factories.Dataset( - owner_org=organization['id'], - type='test-schema', - user=active_user - ) - helpers.call_action( - "follow_dataset", context={"user": subscribed_user["name"]}, - **dataset - ) - ckan_factories.Resource( - package_id=dataset['id'], - user=active_user - ) - return subscribed_user - - -@pytest.mark.usefixtures("clean_db") -@pytest.mark.usefixtures("with_plugins") -@pytest.mark.parametrize("notifications_enabled", [(False), (True)]) -def test_get_phonenumber(notifications_enabled): - user = create_user_with_resources(with_activity=True, with_notifications_enabled=notifications_enabled) - phonenumber = sms_notifications.get_phonenumber(user) - assert phonenumber == "+1234" - - -@pytest.mark.usefixtures("clean_db") -@pytest.mark.usefixtures("with_plugins") -@pytest.mark.parametrize("notifications_enabled", [(False), (True)]) -def test_sms_notifications_disabled_enabled(notifications_enabled): - user = create_user_with_resources(with_activity=True, with_notifications_enabled=notifications_enabled) - notifications = sms_notifications.sms_notifications_enabled(user) - assert notifications == notifications_enabled - - -@pytest.mark.usefixtures("with_request_context") -@pytest.mark.usefixtures("clean_db") -@pytest.mark.usefixtures("with_plugins") -@mock.patch('ckanext.dataset_subscriptions.actions.sms_notifications.client.messages.create') -def test_if_notifications_are_generated(create_message_mock, sysadmin_context): - create_user_with_resources(with_activity=True, with_notifications_enabled=True) - expected_sid = 'SM87105da94bff44b999e4e6eb90d8eb6a' - create_message_mock.return_value.sid = expected_sid - sid = helpers.call_action("send_sms_notifications") - print(sid) - assert create_message_mock.called is True - assert sid[0] == expected_sid diff --git a/ckanext/dataset_subscriptions/tests/actions/test_user.py b/ckanext/dataset_subscriptions/tests/actions/test_user.py new file mode 100644 index 0000000..d136b03 --- /dev/null +++ b/ckanext/dataset_subscriptions/tests/actions/test_user.py @@ -0,0 +1,79 @@ +import pytest +from ckan.plugins import toolkit +from ckan.tests import helpers +from ckan.tests import factories +from unittest import mock +from twilio.base.exceptions import TwilioRestException +from contextlib import nullcontext + + +@pytest.mark.usefixtures("with_plugins", "clean_db") +class TestUserActions(): + + def test_user_create_supports_plugin_extras(self, sysadmin_context): + user_dict = { + "name": "test_user_001", + "fullname": "Mr. Test User", + "password": "fjelltopp", + "display_name": "Mr. Test User", + "email": "test_user_001@ckan.org", + "phonenumber": "+447855474558", + "activity_streams_sms_notifications": True + } + created_user = helpers.call_action('user_create', context=sysadmin_context, **user_dict) + for key in ["phonenumber", "activity_streams_sms_notifications"]: + assert created_user[key] == user_dict[key] + + def test_user_update_supports_plugin_extras(self, sysadmin_context): + user = factories.User() + user_dict = {**user, **{ + "phonenumber": 123, + "activity_streams_sms_notifications": True + } + } + helpers.call_action('user_update', **user_dict) + updated_user = helpers.call_action( + 'user_show', + context=sysadmin_context, + include_plugin_extras=True, + **user_dict + ) + for key in ["phonenumber", "activity_streams_sms_notifications"]: + assert updated_user[key] == user_dict[key] + + @mock.patch('ckanext.dataset_subscriptions.actions.user.client') + def test_user_validate_plugin_extras_valid_phonenumber(self, client_mock, sysadmin_context): + phonenumber = 123 + client_mock.lookups.phone_numbers(phonenumber).fetch.side_effect = TwilioRestException(404, "not found") + user_dict = { + "name": "test_user_001", + "fullname": "Mr. Test User", + "password": "fjelltopp", + "display_name": "Mr. Test User", + "email": "test_user_001@ckan.org", + "phonenumber": phonenumber, + "activity_streams_sms_notifications": True + } + with pytest.raises(toolkit.ValidationError, match="Invalid phonenumber"): + helpers.call_action('user_create', context=sysadmin_context, **user_dict) + + @pytest.mark.parametrize('phonenumber, enable_sms, enable_whatsapp, expectation', [ + ("", False, False, nullcontext(1)), + ("", False, True, pytest.raises(toolkit.ValidationError)), + ("", True, False, pytest.raises(toolkit.ValidationError)), + ("", True, True, pytest.raises(toolkit.ValidationError)) + ]) + def test_user_validate_plugin_extras_requires_phonenumber(self, phonenumber, enable_sms, + enable_whatsapp, expectation, sysadmin_context): + user_dict = { + "name": "test_user_001", + "fullname": "Mr. Test User", + "password": "fjelltopp", + "display_name": "Mr. Test User", + "email": "test_user_001@ckan.org", + "phonenumber": phonenumber, + "activity_streams_sms_notifications": enable_sms, + "activity_streams_whatsapp_notifications": enable_whatsapp + } + with expectation: + helpers.call_action('user_create', context=sysadmin_context, **user_dict) diff --git a/ckanext/dataset_subscriptions/tests/conftest.py b/ckanext/dataset_subscriptions/tests/conftest.py index e64b7e3..b751d46 100644 --- a/ckanext/dataset_subscriptions/tests/conftest.py +++ b/ckanext/dataset_subscriptions/tests/conftest.py @@ -1,6 +1,16 @@ import pytest +from ckan.tests import factories as ckan_factories + + +@pytest.fixture +def sysadmin_context(): + sysadmin = ckan_factories.Sysadmin() + # helpers.call_action sets 'ignore_auth' to True by default + context = {'user': sysadmin['name'], 'ignore_auth': False} + return context + @pytest.fixture() def clean_db(reset_db, migrate_db_for): reset_db() - migrate_db_for("activity") \ No newline at end of file + migrate_db_for("activity") diff --git a/test.ini b/test.ini index 8bc9b47..46e581d 100644 --- a/test.ini +++ b/test.ini @@ -13,6 +13,7 @@ ckan.plugins = dataset_subscriptions activity ckanext.dataset_subscriptions.twilio_account_sid = dummy_sid ckanext.dataset_subscriptions.twilio_auth_token = dummy_token ckanext.dataset_subscriptions.sms_sender_nr = +11234 +ckanext.dataset_subscriptions.whatsapp_sender_nr = +11234 ckanext.dataset_subscriptions.sms_notifications_hours_since = 1 # Logging configuration