Additional Emails - Additional Action

Currently set up as an additional action added to the task in the
configuration file. At each stage (corresponding to the current email
section lables) a template and subject can be specified detailing
the email to be sent. This will be sent to the users email address
or otherwise an override email address set from the task.

In the configuration sending to the users email address can be
turned off with the line
   email_current_user: false

Additionally an email can be sent out to a group of roles within
a project using:
  email_roles:
      - project_admin

Or to a number of specific emails:
  email_additional_addresses:
      - admin@example.org

Or to an address specified in the task cache
  email_in_task_cache: true

(Cache key "additional_emails")
Change-Id: I6d454bdfefb7549322fea6cf0c91fac76b5aa89a
This commit is contained in:
Amelia Cordwell 2017-01-16 11:55:38 +13:00 committed by adrian-turjak
parent 890b154591
commit 58ac750bcc
13 changed files with 496 additions and 24 deletions

View File

@ -186,6 +186,34 @@ DEFAULT_ACTION_SETTINGS:
regions:
RegionOne:
quota_size: small
SendAdditionalEmailAction:
initial:
email_current_user: false
reply: no-reply@example.com
from: bounce+%(task_uuid)s@example.com
subject: "Openstack Email Notification"
template: null
token:
email_current_user: false
reply: no-reply@example.com
from: bounce+%(task_uuid)s@example.com
subject: "Openstack Email Notification"
template: null
completed:
email_current_user: false
reply: no-reply@example.com
from: bounce+%(task_uuid)s@example.com
subject: "Openstack Email Notification"
template: null
# A null template will cause the email not to send
# Also emails to the given roles on the project
# email_roles:
# - project_admin
# Or sends to an email set in the task cache
# email_in_task_cache: true
# Or sends to an arbitrary admin email
# email_additional_addresses:
# - admin@example.org
# These are cascading overrides for the default settings:
TASK_SETTINGS:

View File

@ -0,0 +1,93 @@
import six
from smtplib import SMTPException
from stacktask.api.v1.utils import create_notification
from django.core.mail import EmailMultiAlternatives
from django.template import loader
from django.conf import settings
def send_email(to_addresses, context, conf, task):
"""
Function for sending emails from actions
"""
if not conf.get('template'):
return
if not to_addresses:
return
if isinstance(to_addresses, six.string_types):
to_addresses = [to_addresses]
elif isinstance(to_addresses, set):
to_addresses = list(to_addresses)
text_template = loader.get_template(
conf['template'],
using='include_etc_templates')
html_template = conf.get('html_template', None)
if html_template:
html_template = loader.get_template(
html_template,
using='include_etc_templates')
try:
message = text_template.render(context)
# from_email is the return-path and is distinct from the
# message headers
from_email = conf.get('from')
if not from_email:
from_email = conf['reply']
elif "%(task_uuid)s" in from_email:
from_email = from_email % {'task_uuid': task.uuid}
reply_email = conf['reply']
# these are the message headers which will be visible to
# the email client.
headers = {
'X-StackTask-Task-UUID': task.uuid,
# From needs to be set to be distinct from return-path
'From': reply_email,
'Reply-To': reply_email,
}
email = EmailMultiAlternatives(
conf['subject'],
message,
from_email,
to_addresses,
headers=headers,
)
if html_template:
email.attach_alternative(
html_template.render(context), "text/html")
email.send(fail_silently=False)
return True
except SMTPException as e:
notes = {
'errors':
("Error: '%s' while sending additional email for task: %s"
% (e, task.uuid))
}
errors_conf = settings.TASK_SETTINGS.get(
task.task_type, settings.DEFAULT_TASK_SETTINGS).get(
'errors', {}).get("SMTPException", {})
if errors_conf:
notification = create_notification(
task, notes, error=True,
engines=errors_conf.get('engines', True))
if errors_conf.get('notification') == "acknowledge":
notification.acknowledged = True
notification.save()
else:
create_notification(task, notes, error=True)
return False

View File

@ -0,0 +1,115 @@
# Copyright (C) 2016 Catalyst IT Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
import six
from django.conf import settings
from stacktask.actions.v1.base import BaseAction
from stacktask.actions import user_store
from stacktask.actions.utils import send_email
class SendAdditionalEmailAction(BaseAction):
def set_email(self, conf):
self.emails = set()
if conf.get('email_current_user'):
self.add_note("Adding the current user's email address")
if settings.USERNAME_IS_EMAIL:
self.emails.add(self.action.task.keystone_user['username'])
else:
try:
self.emails.add(self.action.task.keystone_user['email'])
except KeyError:
self.add_note("Could not add current user email address")
if conf.get('email_roles'):
roles = set(conf.get('email_roles'))
project_id = self.action.task.keystone_user['project_id']
self.add_note('Adding email addresses for roles %s in project %s'
% (roles, project_id))
id_manager = user_store.IdentityManager()
users = id_manager.list_users(project_id)
for user in users:
user_roles = [role.name for role in user.roles]
if roles.intersection(user_roles):
if settings.USERNAME_IS_EMAIL:
self.emails.add(user.name)
else:
self.emails.add(user.email)
if conf.get('email_task_cache'):
task_emails = self.task.cache.get('additional_emails', [])
if isinstance(task_emails, six.string_types):
task_emails = [task_emails]
for email in task_emails:
self.emails.add(email)
for email in conf.get('email_additional_addresses', []):
self.emails.add(email)
def _validate(self):
self.action.valid = True
self.action.save()
def _pre_approve(self):
self.perform_action('initial')
def _post_approve(self):
self.perform_action('token')
def _submit(self, data):
self.perform_action('completed')
def perform_action(self, stage):
self._validate()
task = self.action.task
for action in task.actions:
if not action.valid:
return
email_conf = self.settings.get(stage, {})
# If either of these are false we won't be sending anything.
if not email_conf or not email_conf.get('template'):
return
self.set_email(email_conf)
if not self.emails:
self.add_note(self.emails)
self.add_note("Email address not set. Stage: %s" % stage)
return
self.add_note("Sending emails to: %s" % self.emails)
actions = {}
for action in task.actions:
act = action.get_action()
actions[str(act)] = act
context = {
'task': task,
'actions': actions
}
result = send_email(self.emails, context, email_conf, task)
if not result:
self.add_note("Unable to send additional email. Stage: %s" % stage)
else:
self.add_note("Additional email sent. Stage: %s" % stage)

View File

@ -22,6 +22,7 @@ from stacktask.actions.v1.users import (
from stacktask.actions.v1.resources import (
NewDefaultNetworkAction, NewProjectDefaultNetworkAction,
SetProjectQuotaAction)
from stacktask.actions.v1.misc import SendAdditionalEmailAction
# Update settings dict with tuples in the format:
@ -52,3 +53,7 @@ register_action_class(
serializers.NewProjectDefaultNetworkSerializer)
register_action_class(
SetProjectQuotaAction, serializers.SetProjectQuotaSerializer)
# Register Misc actions:
register_action_class(
SendAdditionalEmailAction, serializers.SendAdditionalEmailSerializer)

View File

@ -87,3 +87,7 @@ class AddDefaultUsersToProjectSerializer(serializers.Serializer):
class SetProjectQuotaSerializer(serializers.Serializer):
pass
class SendAdditionalEmailSerializer(serializers.Serializer):
pass

View File

@ -19,7 +19,7 @@ from django.utils import timezone
from stacktask.api import utils
from stacktask.api.v1.views import APIViewWithLogger
from stacktask.api.v1.utils import (
send_email, create_notification, create_token, create_task_hash,
send_stage_email, create_notification, create_token, create_task_hash,
add_task_id_for_roles)
from stacktask.exceptions import SerializerMissingException
@ -213,7 +213,7 @@ class TaskView(APIViewWithLogger):
# send initial confirmation email:
email_conf = class_conf.get('emails', {}).get('initial', None)
send_email(task, email_conf)
send_stage_email(task, email_conf)
action_models = task.actions
approve_list = [act.get_action().auto_approve for act in action_models]
@ -249,7 +249,7 @@ class TaskView(APIViewWithLogger):
# will throw a key error if the token template has not
# been specified
email_conf = class_conf['emails']['token']
send_email(task, email_conf, token)
send_stage_email(task, email_conf, token)
return {'notes': ['created token']}, 200
except KeyError as e:
import traceback
@ -361,7 +361,7 @@ class TaskView(APIViewWithLogger):
self.task_type, settings.DEFAULT_TASK_SETTINGS)
email_conf = class_conf.get(
'emails', {}).get('completed', None)
send_email(task, email_conf)
send_stage_email(task, email_conf)
return {'notes': ["Task completed successfully."]}, 200

View File

@ -0,0 +1,8 @@
Hello,
We have had an email address change request from you. We have sent a confirmation email to your new email: {{ actions.UpdateUserEmailAction.new_email }}
If this was not you please get in touch with an administrator immediately.
Kind Regards,
The Openstack Team

View File

@ -292,6 +292,7 @@ class modify_dict_settings(override_settings):
Available operations:
Standard operations:
- 'update': A dict on dict operation to update final dict with value.
- 'override': Either overrides or adds the value to the dictionary.
- 'delete': Removes the value from the dictionary.
@ -365,6 +366,8 @@ class modify_dict_settings(override_settings):
holding_dict[final_key] = operation['value']
elif op_type == "delete":
del holding_dict[final_key]
elif op_type == "update":
holding_dict[final_key].update(operation['value'])
else:
val = holding_dict.get(final_key, [])
items = operation['value']

View File

@ -1,4 +1,4 @@
# Copyright (C) 2015 Catalyst IT Ltd
# Copyright (C) 2015 Catalyst IT Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
@ -14,16 +14,16 @@
import mock
from django.test.utils import override_settings
from django.core import mail
from rest_framework import status
from stacktask.api.models import Task, Token
from stacktask.api.v1.tests import (FakeManager, setup_temp_cache,
StacktaskAPITestCase)
StacktaskAPITestCase, modify_dict_settings)
from stacktask.api.v1 import tests
from django.core import mail
from django.test.utils import override_settings
@mock.patch('stacktask.actions.user_store.IdentityManager',
FakeManager)
@ -752,26 +752,227 @@ class TaskViewTests(StacktaskAPITestCase):
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = {'email': "new_test@example.com", 'username': "new",
'project_name': 'new_project'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {'notes': ['task created']})
new_task = Task.objects.all()[0]
url = "/v1/tasks/" + new_task.uuid
headers = {
'project_name': "test_project",
'project_id': "test_project_id",
'roles': "admin,_member_",
'roles': "admin",
'username': "test",
'user_id': "test_user_id",
'email': "test@example.com",
'authenticated': True
}
response = self.client.post(url, {'approved': True}, format='json',
headers=headers)
self.assertEqual(response.status_code, status.HTTP_200_OK)
new_token = Token.objects.all()[0]
url = "/v1/tokens/" + new_token.token
data = {'confirm': True, 'password': '1234'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
@modify_dict_settings(
TASK_SETTINGS=[
{'key_list': ['invite_user', 'additional_actions'],
'operation': 'append',
'value': ['SendAdditionalEmailAction']},
{'key_list': ['invite_user', 'action_settings',
'SendAdditionalEmailAction', 'initial'],
'operation': 'update',
'value': {
'subject': 'email_update_additional',
'template': 'email_update_started.txt',
'email_roles': ['project_admin'],
'email_current_user': False,
}
}
])
def test_additional_emails_roles(self):
"""
Tests the sending of additional emails to a set of roles in a project
"""
# NOTE(amelia): sending this email here is probably not the intended
# case. It would be more useful in cases such as a quota update or a
# child project being created that all the project admins should be
# notified of
project = mock.Mock()
project.id = 'test_project_id'
project.name = 'test_project'
project.domain = 'default'
user = mock.Mock()
user.id = 'test_user_id'
user.name = "test@example.com"
user.email = "test@example.com"
user.domain = 'default'
user2 = mock.Mock()
user2.id = 'test_user_id_2'
user2.name = "test2@example.com"
user2.email = "test2@example.com"
user2.domain = 'default'
user3 = mock.Mock()
user3.id = 'test_user_id_3'
user3.name = "test3@example.com"
user3.email = "test3@example.com"
user3.domain = 'default'
project.roles = {user.id: ['project_admin', '_member_'],
user2.id: ['project_admin', '_member_'],
user3.id: ['project_mod', '_member_']}
setup_temp_cache({'test_project': project},
{user.id: user, user2.id: user2, user3.id: user3})
url = "/v1/actions/InviteUser"
headers = {
'project_name': "test_project",
'project_id': "test_project_id",
'roles': "project_admin,_member_,project_mod",
'username': "test@example.com",
'user_id': "test_user_id",
'authenticated': True
}
new_task = Task.objects.all()[0]
url = "/v1/tasks/" + new_task.uuid
response = self.client.post(url, {'approved': True}, format='json',
headers=headers)
data = {'email': "new_test@example.com",
'roles': ['_member_']}
response = self.client.post(url, data, format='json', headers=headers)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
response.data,
{'notes': ['created token']}
)
self.assertEqual(response.data, {'notes': ['created token']})
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(len(mail.outbox[0].to), 2)
self.assertEqual(set(mail.outbox[0].to),
set([user.email, user2.email]))
self.assertEqual(mail.outbox[0].subject, 'email_update_additional')
# Test that the token email gets sent to the other addresses
self.assertEqual(mail.outbox[1].to[0], 'new_test@example.com')
new_token = Token.objects.all()[0]
url = "/v1/tokens/" + new_token.token
data = {'confirm': True, 'password': '1234'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
@modify_dict_settings(
TASK_SETTINGS=[
{'key_list': ['invite_user', 'additional_actions'],
'operation': 'override',
'value': ['SendAdditionalEmailAction']},
{'key_list': ['invite_user', 'action_settings',
'SendAdditionalEmailAction', 'initial'],
'operation': 'update',
'value':{
'subject': 'invite_user_additional',
'template': 'email_update_started.txt',
'email_additional_addresses': ['admin@example.com'],
'email_current_user': False,
}
}
])
def test_email_additional_addresses(self):
"""
Tests the sending of additional emails an admin email set in
the conf
"""
project = mock.Mock()
project.id = 'test_project_id'
project.name = 'test_project'
project.domain = 'default'
user = mock.Mock()
user.id = 'test_user_id'
user.name = "test@example.com"
user.email = "test@example.com"
user.domain = 'default'
project.roles = {user.id: ['project_admin', '_member_']}
setup_temp_cache({'test_project': project}, {user.id: user, })
url = "/v1/actions/InviteUser"
headers = {
'project_name': "test_project",
'project_id': "test_project_id",
'roles': "project_admin,_member_,project_mod",
'username': "test@example.com",
'user_id': "test_user_id",
'authenticated': True
}
data = {'email': "new_test@example.com", 'roles': ['_member_']}
response = self.client.post(url, data, format='json', headers=headers)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data, {'notes': ['created token']})
self.assertEqual(len(mail.outbox), 2)
self.assertEqual(set(mail.outbox[0].to),
set(['admin@example.com']))
self.assertEqual(mail.outbox[0].subject, 'invite_user_additional')
# Test that the token email gets sent to the other addresses
self.assertEqual(mail.outbox[1].to[0], 'new_test@example.com')
new_token = Token.objects.all()[0]
url = "/v1/tokens/" + new_token.token
data = {'password': 'testpassword'}
response = self.client.post(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
@modify_dict_settings(
TASK_SETTINGS=[
{'key_list': ['invite_user', 'additional_actions'],
'operation': 'override',
'value': ['SendAdditionalEmailAction']},
{'key_list': ['invite_user', 'action_settings',
'SendAdditionalEmailAction', 'initial'],
'operation': 'update',
'value':{
'subject': 'invite_user_additional',
'template': 'email_update_started.txt',
'email_additional_addresses': ['admin@example.com'],
'email_current_user': False,
}
}
])
def test_email_additional_action_invalid(self):
"""
The additional email actions should not send an email if the
action is invalid.
"""
setup_temp_cache({}, {})
url = "/v1/actions/InviteUser"
headers = {
'project_name': "test_project",
'project_id': "test_project_id",
'roles': "project_admin,_member_,project_mod",
'username': "test@example.com",
'user_id': "test_user_id",
'authenticated': True
}
data = {'email': "test@example.com", 'roles': ["_member_"],
'project_id': 'test_project_id'}
response = self.client.post(url, data, format='json', headers=headers)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data, {'errors': ['actions invalid']})
self.assertEqual(len(mail.outbox), 0)

View File

@ -44,7 +44,7 @@ def create_token(task):
return token
def send_email(task, email_conf, token=None):
def send_stage_email(task, email_conf, token=None):
if not email_conf:
return

View File

@ -25,7 +25,7 @@ from rest_framework.views import APIView
from stacktask.api import utils
from stacktask.api.models import Notification, Task, Token
from stacktask.api.v1.utils import (
create_notification, create_token, parse_filters, send_email)
create_notification, create_token, parse_filters, send_stage_email)
class APIViewWithLogger(APIView):
@ -437,7 +437,7 @@ class TaskDetail(APIViewWithLogger):
# will throw a key error if the token template has not
# been specified
email_conf = class_conf['emails']['token']
send_email(task, email_conf, token)
send_stage_email(task, email_conf, token)
return Response({'notes': ['created token']},
status=200)
except KeyError as e:
@ -493,7 +493,7 @@ class TaskDetail(APIViewWithLogger):
task.task_type, settings.DEFAULT_TASK_SETTINGS)
email_conf = class_conf.get(
'emails', {}).get('completed', None)
send_email(task, email_conf)
send_stage_email(task, email_conf)
return Response(
{'notes': ["Task completed successfully."]},
@ -610,7 +610,7 @@ class TokenList(APIViewWithLogger):
# will throw a key error if the token template has not
# been specified
email_conf = class_conf['emails']['token']
send_email(task, email_conf, token)
send_stage_email(task, email_conf, token)
except KeyError as e:
notes = {
'errors': [
@ -786,7 +786,7 @@ class TokenDetail(APIViewWithLogger):
token.task.task_type, settings.DEFAULT_TASK_SETTINGS)
email_conf = class_conf.get(
'emails', {}).get('completed', None)
send_email(token.task, email_conf)
send_stage_email(token.task, email_conf)
return Response(
{'notes': ["Token submitted successfully."]},

View File

@ -152,6 +152,20 @@ DEFAULT_ACTION_SETTINGS = {
}
},
},
'SendAdditionalEmailAction': {
'initial': {
'reply': 'no-reply@example.com',
'from': 'bounce+%(task_uuid)s@example.com'
},
'token': {
'reply': 'no-reply@example.com',
'from': 'bounce+%(task_uuid)s@example.com'
},
'completed': {
'reply': 'no-reply@example.com',
'from': 'bounce+%(task_uuid)s@example.com'
},
},
}
TASK_SETTINGS = {

View File

@ -20,6 +20,7 @@ def dict_merge(a, b):
Recursively merges two dicts.
If both a and b have a key who's value is a dict then dict_merge is called
on both values and the result stored in the returned dictionary.
B is the override.
"""
if not isinstance(b, dict):
return b