mirror of
https://github.com/ansible/awx.git
synced 2026-05-08 01:47:35 -02:30
Merge branch 'notifications_work' into devel
* notifications_work: (23 commits) Updates to notification unit tests after @wwitzel3's feedback Fix some notifications issues and write some tests Add notification system documentation Clean up flake8 related issues Fixing up some unicode issues Implement tower ui view url on models Sanity check and force proper types in admin check Proper type for in check Adding migration and base notification type Add a periodic administrative notification Refactor message generator Support notification password field encryption Notification configuration type checking Refactor NotificationTemplate to Notifier Implement irc notification backend Add webhook notification backend Pagerduty and Hipchat backends plus some cleanup Notification serializers, views, and tasks Implement notification serializer and validations Notification endpoints and url expositions ...
This commit is contained in:
@@ -13,7 +13,7 @@ from rest_framework import serializers
|
||||
from rest_framework.request import clone_request
|
||||
|
||||
# Ansible Tower
|
||||
from awx.main.models import InventorySource
|
||||
from awx.main.models import InventorySource, Notifier
|
||||
|
||||
|
||||
class Metadata(metadata.SimpleMetadata):
|
||||
@@ -76,6 +76,12 @@ class Metadata(metadata.SimpleMetadata):
|
||||
get_group_by_choices = getattr(InventorySource, 'get_%s_group_by_choices' % cp)
|
||||
field_info['%s_group_by_choices' % cp] = get_group_by_choices()
|
||||
|
||||
# Special handling of notification configuration where the required properties
|
||||
# are conditional on the type selected.
|
||||
if field.field_name == 'notification_configuration':
|
||||
for (notification_type_name, notification_tr_name, notification_type_class) in Notifier.NOTIFICATION_TYPES:
|
||||
field_info[notification_type_name] = notification_type_class.init_parameters
|
||||
|
||||
# Update type of fields returned...
|
||||
if field.field_name == 'type':
|
||||
field_info['type'] = 'multiple choice'
|
||||
|
||||
@@ -481,6 +481,8 @@ class BaseSerializer(serializers.ModelSerializer):
|
||||
class EmptySerializer(serializers.Serializer):
|
||||
pass
|
||||
|
||||
class EmptySerializer(serializers.Serializer):
|
||||
pass
|
||||
|
||||
class BaseFactSerializer(DocumentSerializer):
|
||||
|
||||
@@ -794,7 +796,11 @@ class OrganizationSerializer(BaseSerializer):
|
||||
users = reverse('api:organization_users_list', args=(obj.pk,)),
|
||||
admins = reverse('api:organization_admins_list', args=(obj.pk,)),
|
||||
teams = reverse('api:organization_teams_list', args=(obj.pk,)),
|
||||
activity_stream = reverse('api:organization_activity_stream_list', args=(obj.pk,))
|
||||
activity_stream = reverse('api:organization_activity_stream_list', args=(obj.pk,)),
|
||||
notifiers = reverse('api:organization_notifiers_list', args=(obj.pk,)),
|
||||
notifiers_any = reverse('api:organization_notifiers_any_list', args=(obj.pk,)),
|
||||
notifiers_success = reverse('api:organization_notifiers_success_list', args=(obj.pk,)),
|
||||
notifiers_error = reverse('api:organization_notifiers_error_list', args=(obj.pk,)),
|
||||
))
|
||||
return res
|
||||
|
||||
@@ -864,6 +870,9 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
|
||||
project_updates = reverse('api:project_updates_list', args=(obj.pk,)),
|
||||
schedules = reverse('api:project_schedules_list', args=(obj.pk,)),
|
||||
activity_stream = reverse('api:project_activity_stream_list', args=(obj.pk,)),
|
||||
notifiers_any = reverse('api:project_notifiers_any_list', args=(obj.pk,)),
|
||||
notifiers_success = reverse('api:project_notifiers_success_list', args=(obj.pk,)),
|
||||
notifiers_error = reverse('api:project_notifiers_error_list', args=(obj.pk,)),
|
||||
))
|
||||
# Backwards compatibility.
|
||||
if obj.current_update:
|
||||
@@ -909,6 +918,7 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer):
|
||||
res.update(dict(
|
||||
project = reverse('api:project_detail', args=(obj.project.pk,)),
|
||||
cancel = reverse('api:project_update_cancel', args=(obj.pk,)),
|
||||
notifications = reverse('api:project_update_notifications_list', args=(obj.pk,)),
|
||||
))
|
||||
return res
|
||||
|
||||
@@ -1316,6 +1326,9 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
|
||||
activity_stream = reverse('api:inventory_activity_stream_list', args=(obj.pk,)),
|
||||
hosts = reverse('api:inventory_source_hosts_list', args=(obj.pk,)),
|
||||
groups = reverse('api:inventory_source_groups_list', args=(obj.pk,)),
|
||||
notifiers_any = reverse('api:inventory_source_notifiers_any_list', args=(obj.pk,)),
|
||||
notifiers_success = reverse('api:inventory_source_notifiers_success_list', args=(obj.pk,)),
|
||||
notifiers_error = reverse('api:inventory_source_notifiers_error_list', args=(obj.pk,)),
|
||||
))
|
||||
if obj.inventory and obj.inventory.active:
|
||||
res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,))
|
||||
@@ -1360,6 +1373,7 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri
|
||||
res.update(dict(
|
||||
inventory_source = reverse('api:inventory_source_detail', args=(obj.inventory_source.pk,)),
|
||||
cancel = reverse('api:inventory_update_cancel', args=(obj.pk,)),
|
||||
notifications = reverse('api:inventory_update_notifications_list', args=(obj.pk,)),
|
||||
))
|
||||
return res
|
||||
|
||||
@@ -1575,6 +1589,9 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer):
|
||||
schedules = reverse('api:job_template_schedules_list', args=(obj.pk,)),
|
||||
activity_stream = reverse('api:job_template_activity_stream_list', args=(obj.pk,)),
|
||||
launch = reverse('api:job_template_launch', args=(obj.pk,)),
|
||||
notifiers_any = reverse('api:job_template_notifiers_any_list', args=(obj.pk,)),
|
||||
notifiers_success = reverse('api:job_template_notifiers_success_list', args=(obj.pk,)),
|
||||
notifiers_error = reverse('api:job_template_notifiers_error_list', args=(obj.pk,)),
|
||||
))
|
||||
if obj.host_config_key:
|
||||
res['callback'] = reverse('api:job_template_callback', args=(obj.pk,))
|
||||
@@ -1629,6 +1646,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
|
||||
job_tasks = reverse('api:job_job_tasks_list', args=(obj.pk,)),
|
||||
job_host_summaries = reverse('api:job_job_host_summaries_list', args=(obj.pk,)),
|
||||
activity_stream = reverse('api:job_activity_stream_list', args=(obj.pk,)),
|
||||
notifications = reverse('api:job_notifications_list', args=(obj.pk,)),
|
||||
))
|
||||
if obj.job_template and obj.job_template.active:
|
||||
res['job_template'] = reverse('api:job_template_detail',
|
||||
@@ -2044,6 +2062,79 @@ class JobLaunchSerializer(BaseSerializer):
|
||||
attrs = super(JobLaunchSerializer, self).validate(attrs)
|
||||
return attrs
|
||||
|
||||
class NotifierSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Notifier
|
||||
fields = ('*', 'organization', 'notification_type', 'notification_configuration')
|
||||
|
||||
type_map = {"string": (str, unicode),
|
||||
"int": (int,),
|
||||
"bool": (bool,),
|
||||
"list": (list,),
|
||||
"password": (str, unicode),
|
||||
"object": (dict,)}
|
||||
|
||||
def to_representation(self, obj):
|
||||
ret = super(NotifierSerializer, self).to_representation(obj)
|
||||
for field in obj.notification_class.init_parameters:
|
||||
if field in ret['notification_configuration'] and \
|
||||
force_text(ret['notification_configuration'][field]).startswith('$encrypted$'):
|
||||
ret['notification_configuration'][field] = '$encrypted$'
|
||||
return ret
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(NotifierSerializer, self).get_related(obj)
|
||||
res.update(dict(
|
||||
test = reverse('api:notifier_test', args=(obj.pk,)),
|
||||
notifications = reverse('api:notifier_notification_list', args=(obj.pk,)),
|
||||
))
|
||||
if obj.organization and obj.organization.active:
|
||||
res['organization'] = reverse('api:organization_detail', args=(obj.organization.pk,))
|
||||
return res
|
||||
|
||||
def validate(self, attrs):
|
||||
notification_class = Notifier.CLASS_FOR_NOTIFICATION_TYPE[attrs['notification_type']]
|
||||
missing_fields = []
|
||||
incorrect_type_fields = []
|
||||
if 'notification_configuration' not in attrs:
|
||||
return attrs
|
||||
for field in notification_class.init_parameters:
|
||||
if field not in attrs['notification_configuration']:
|
||||
missing_fields.append(field)
|
||||
continue
|
||||
field_val = attrs['notification_configuration'][field]
|
||||
field_type = notification_class.init_parameters[field]['type']
|
||||
expected_types = self.type_map[field_type]
|
||||
if not type(field_val) in expected_types:
|
||||
incorrect_type_fields.append((field, field_type))
|
||||
continue
|
||||
if field_type == "password" and field_val.startswith('$encrypted$'):
|
||||
missing_fields.append(field)
|
||||
error_list = []
|
||||
if missing_fields:
|
||||
error_list.append("Missing required fields for Notification Configuration: {}".format(missing_fields))
|
||||
if incorrect_type_fields:
|
||||
for type_field_error in incorrect_type_fields:
|
||||
error_list.append("Configuration field '{}' incorrect type, expected {}".format(type_field_error[0],
|
||||
type_field_error[1]))
|
||||
if error_list:
|
||||
raise serializers.ValidationError(error_list)
|
||||
return attrs
|
||||
|
||||
class NotificationSerializer(BaseSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Notification
|
||||
fields = ('*', '-name', '-description', 'notifier', 'error', 'status', 'notifications_sent',
|
||||
'notification_type', 'recipients', 'subject')
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(NotificationSerializer, self).get_related(obj)
|
||||
res.update(dict(
|
||||
notifier = reverse('api:notifier_detail', args=(obj.notifier.pk,)),
|
||||
))
|
||||
return res
|
||||
|
||||
class ScheduleSerializer(BaseSerializer):
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ organization_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/projects/$', 'organization_projects_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/teams/$', 'organization_teams_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'organization_activity_stream_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers/$', 'organization_notifiers_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'organization_notifiers_any_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'organization_notifiers_error_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'organization_notifiers_success_list'),
|
||||
)
|
||||
|
||||
user_urls = patterns('awx.api.views',
|
||||
@@ -44,12 +48,16 @@ project_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/project_updates/$', 'project_updates_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'project_activity_stream_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/schedules/$', 'project_schedules_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'project_notifiers_any_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'project_notifiers_error_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'project_notifiers_success_list'),
|
||||
)
|
||||
|
||||
project_update_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/$', 'project_update_detail'),
|
||||
url(r'^(?P<pk>[0-9]+)/cancel/$', 'project_update_cancel'),
|
||||
url(r'^(?P<pk>[0-9]+)/stdout/$', 'project_update_stdout'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifications/$', 'project_update_notifications_list'),
|
||||
)
|
||||
|
||||
team_urls = patterns('awx.api.views',
|
||||
@@ -121,12 +129,16 @@ inventory_source_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/schedules/$', 'inventory_source_schedules_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/groups/$', 'inventory_source_groups_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/hosts/$', 'inventory_source_hosts_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'inventory_source_notifiers_any_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'inventory_source_notifiers_error_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'inventory_source_notifiers_success_list'),
|
||||
)
|
||||
|
||||
inventory_update_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/$', 'inventory_update_detail'),
|
||||
url(r'^(?P<pk>[0-9]+)/cancel/$', 'inventory_update_cancel'),
|
||||
url(r'^(?P<pk>[0-9]+)/stdout/$', 'inventory_update_stdout'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifications/$', 'inventory_update_notifications_list'),
|
||||
)
|
||||
|
||||
inventory_script_urls = patterns('awx.api.views',
|
||||
@@ -154,6 +166,9 @@ job_template_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/schedules/$', 'job_template_schedules_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/survey_spec/$', 'job_template_survey_spec'),
|
||||
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'job_template_activity_stream_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'job_template_notifiers_any_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'job_template_notifiers_error_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'job_template_notifiers_success_list'),
|
||||
)
|
||||
|
||||
job_urls = patterns('awx.api.views',
|
||||
@@ -168,6 +183,7 @@ job_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/job_tasks/$', 'job_job_tasks_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'job_activity_stream_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/stdout/$', 'job_stdout'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifications/$', 'job_notifications_list'),
|
||||
)
|
||||
|
||||
job_host_summary_urls = patterns('awx.api.views',
|
||||
@@ -210,6 +226,18 @@ system_job_urls = patterns('awx.api.views',
|
||||
url(r'^(?P<pk>[0-9]+)/cancel/$', 'system_job_cancel'),
|
||||
)
|
||||
|
||||
notifier_urls = patterns('awx.api.views',
|
||||
url(r'^$', 'notifier_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/$', 'notifier_detail'),
|
||||
url(r'^(?P<pk>[0-9]+)/test/$', 'notifier_test'),
|
||||
url(r'^(?P<pk>[0-9]+)/notifications/$', 'notifier_notification_list'),
|
||||
)
|
||||
|
||||
notification_urls = patterns('awx.api.views',
|
||||
url(r'^$', 'notification_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/$', 'notification_detail'),
|
||||
)
|
||||
|
||||
schedule_urls = patterns('awx.api.views',
|
||||
url(r'^$', 'schedule_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/$', 'schedule_detail'),
|
||||
@@ -258,6 +286,8 @@ v1_urls = patterns('awx.api.views',
|
||||
url(r'^ad_hoc_command_events/', include(ad_hoc_command_event_urls)),
|
||||
url(r'^system_job_templates/', include(system_job_template_urls)),
|
||||
url(r'^system_jobs/', include(system_job_urls)),
|
||||
url(r'^notifiers/', include(notifier_urls)),
|
||||
url(r'^notifications/', include(notification_urls)),
|
||||
url(r'^unified_job_templates/$', 'unified_job_template_list'),
|
||||
url(r'^unified_jobs/$', 'unified_job_list'),
|
||||
url(r'^activity_stream/', include(activity_stream_urls)),
|
||||
|
||||
168
awx/api/views.py
168
awx/api/views.py
@@ -56,7 +56,7 @@ from social.backends.utils import load_backends
|
||||
|
||||
# AWX
|
||||
from awx.main.task_engine import TaskSerializer, TASK_FILE, TEMPORARY_TASK_FILE
|
||||
from awx.main.tasks import mongodb_control
|
||||
from awx.main.tasks import mongodb_control, send_notifications
|
||||
from awx.main.access import get_user_queryset
|
||||
from awx.main.ha import is_ha_environment
|
||||
from awx.api.authentication import TaskAuthentication, TokenGetAuthentication
|
||||
@@ -135,6 +135,8 @@ class ApiV1RootView(APIView):
|
||||
data['system_job_templates'] = reverse('api:system_job_template_list')
|
||||
data['system_jobs'] = reverse('api:system_job_list')
|
||||
data['schedules'] = reverse('api:schedule_list')
|
||||
data['notifiers'] = reverse('api:notifier_list')
|
||||
data['notifications'] = reverse('api:notification_list')
|
||||
data['unified_job_templates'] = reverse('api:unified_job_template_list')
|
||||
data['unified_jobs'] = reverse('api:unified_job_list')
|
||||
data['activity_stream'] = reverse('api:activity_stream_list')
|
||||
@@ -266,6 +268,7 @@ class ApiV1ConfigView(APIView):
|
||||
# If the license is valid, write it to disk.
|
||||
if license_data['valid_key']:
|
||||
tower_settings.LICENSE = data_actual
|
||||
tower_settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host())
|
||||
|
||||
# Spawn a task to ensure that MongoDB is started (or stopped)
|
||||
# as appropriate, based on whether the license uses it.
|
||||
@@ -696,6 +699,35 @@ class OrganizationActivityStreamList(SubListAPIView):
|
||||
# Okay, let it through.
|
||||
return super(type(self), self).get(request, *args, **kwargs)
|
||||
|
||||
class OrganizationNotifiersList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = Organization
|
||||
relationship = 'notifiers'
|
||||
parent_key = 'organization'
|
||||
|
||||
class OrganizationNotifiersAnyList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = Organization
|
||||
relationship = 'notifiers_any'
|
||||
|
||||
class OrganizationNotifiersErrorList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = Organization
|
||||
relationship = 'notifiers_error'
|
||||
|
||||
class OrganizationNotifiersSuccessList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = Organization
|
||||
relationship = 'notifiers_success'
|
||||
|
||||
class TeamList(ListCreateAPIView):
|
||||
|
||||
model = Team
|
||||
@@ -861,6 +893,26 @@ class ProjectActivityStreamList(SubListAPIView):
|
||||
return qs.filter(project=parent)
|
||||
return qs.filter(Q(project=parent) | Q(credential__in=parent.credential))
|
||||
|
||||
class ProjectNotifiersAnyList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = Project
|
||||
relationship = 'notifiers_any'
|
||||
|
||||
class ProjectNotifiersErrorList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = Project
|
||||
relationship = 'notifiers_error'
|
||||
|
||||
class ProjectNotifiersSuccessList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = Project
|
||||
relationship = 'notifiers_success'
|
||||
|
||||
class ProjectUpdatesList(SubListAPIView):
|
||||
|
||||
@@ -911,6 +963,12 @@ class ProjectUpdateCancel(RetrieveAPIView):
|
||||
else:
|
||||
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||
|
||||
class ProjectUpdateNotificationsList(SubListAPIView):
|
||||
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
parent_model = Project
|
||||
relationship = 'notifications'
|
||||
|
||||
class UserList(ListCreateAPIView):
|
||||
|
||||
@@ -1783,6 +1841,27 @@ class InventorySourceActivityStreamList(SubListAPIView):
|
||||
# Okay, let it through.
|
||||
return super(type(self), self).get(request, *args, **kwargs)
|
||||
|
||||
class InventorySourceNotifiersAnyList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = InventorySource
|
||||
relationship = 'notifiers_any'
|
||||
|
||||
class InventorySourceNotifiersErrorList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = InventorySource
|
||||
relationship = 'notifiers_error'
|
||||
|
||||
class InventorySourceNotifiersSuccessList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = InventorySource
|
||||
relationship = 'notifiers_success'
|
||||
|
||||
class InventorySourceHostsList(SubListAPIView):
|
||||
|
||||
model = Host
|
||||
@@ -1847,6 +1926,13 @@ class InventoryUpdateCancel(RetrieveAPIView):
|
||||
else:
|
||||
return self.http_method_not_allowed(request, *args, **kwargs)
|
||||
|
||||
class InventoryUpdateNotificationsList(SubListAPIView):
|
||||
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
parent_model = InventoryUpdate
|
||||
relationship = 'notifications'
|
||||
|
||||
class JobTemplateList(ListCreateAPIView):
|
||||
|
||||
model = JobTemplate
|
||||
@@ -2016,6 +2102,27 @@ class JobTemplateActivityStreamList(SubListAPIView):
|
||||
# Okay, let it through.
|
||||
return super(type(self), self).get(request, *args, **kwargs)
|
||||
|
||||
class JobTemplateNotifiersAnyList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = JobTemplate
|
||||
relationship = 'notifiers_any'
|
||||
|
||||
class JobTemplateNotifiersErrorList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = JobTemplate
|
||||
relationship = 'notifiers_error'
|
||||
|
||||
class JobTemplateNotifiersSuccessList(SubListCreateAttachDetachAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
parent_model = JobTemplate
|
||||
relationship = 'notifiers_success'
|
||||
|
||||
class JobTemplateCallback(GenericAPIView):
|
||||
|
||||
model = JobTemplate
|
||||
@@ -2349,6 +2456,13 @@ class JobRelaunch(RetrieveAPIView, GenericAPIView):
|
||||
headers = {'Location': new_job.get_absolute_url()}
|
||||
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
class JobNotificationsList(SubListAPIView):
|
||||
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
parent_model = Job
|
||||
relationship = 'notifications'
|
||||
|
||||
class BaseJobHostSummariesList(SubListAPIView):
|
||||
|
||||
model = JobHostSummary
|
||||
@@ -3002,6 +3116,58 @@ class AdHocCommandStdout(UnifiedJobStdout):
|
||||
model = AdHocCommand
|
||||
new_in_220 = True
|
||||
|
||||
class NotifierList(ListCreateAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
new_in_300 = True
|
||||
|
||||
class NotifierDetail(RetrieveUpdateDestroyAPIView):
|
||||
|
||||
model = Notifier
|
||||
serializer_class = NotifierSerializer
|
||||
new_in_300 = True
|
||||
|
||||
class NotifierTest(GenericAPIView):
|
||||
|
||||
view_name = 'Notifier Test'
|
||||
model = Notifier
|
||||
serializer_class = EmptySerializer
|
||||
new_in_300 = True
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
obj = self.get_object()
|
||||
notification = obj.generate_notification("Tower Notification Test {} {}".format(obj.id, tower_settings.TOWER_URL_BASE),
|
||||
{"body": "Ansible Tower Test Notification {} {}".format(obj.id, tower_settings.TOWER_URL_BASE)})
|
||||
if not notification:
|
||||
return Response({}, status=status.HTTP_400_BAD_REQUEST)
|
||||
else:
|
||||
send_notifications.delay([notification.id])
|
||||
headers = {'Location': notification.get_absolute_url()}
|
||||
return Response({"notification": notification.id},
|
||||
headers=headers,
|
||||
status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
class NotifierNotificationList(SubListAPIView):
|
||||
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
parent_model = Notifier
|
||||
relationship = 'notifications'
|
||||
parent_key = 'notifier'
|
||||
|
||||
class NotificationList(ListAPIView):
|
||||
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
new_in_300 = True
|
||||
|
||||
class NotificationDetail(RetrieveAPIView):
|
||||
|
||||
model = Notification
|
||||
serializer_class = NotificationSerializer
|
||||
new_in_300 = True
|
||||
|
||||
class ActivityStreamList(SimpleListAPIView):
|
||||
|
||||
model = ActivityStream
|
||||
|
||||
@@ -1486,6 +1486,31 @@ class ScheduleAccess(BaseAccess):
|
||||
else:
|
||||
return False
|
||||
|
||||
class NotifierAccess(BaseAccess):
|
||||
'''
|
||||
I can see/use a notifier if I have permission to
|
||||
'''
|
||||
model = Notifier
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.model.objects.filter(active=True).distinct()
|
||||
if self.user.is_superuser:
|
||||
return qs
|
||||
return qs
|
||||
|
||||
class NotificationAccess(BaseAccess):
|
||||
'''
|
||||
I can see/use a notification if I have permission to
|
||||
'''
|
||||
model = Notification
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.model.objects.distinct()
|
||||
if self.user.is_superuser:
|
||||
return qs
|
||||
return qs
|
||||
|
||||
|
||||
class ActivityStreamAccess(BaseAccess):
|
||||
'''
|
||||
I can see activity stream events only when I have permission on all objects included in the event
|
||||
@@ -1685,3 +1710,5 @@ register_access(UnifiedJob, UnifiedJobAccess)
|
||||
register_access(ActivityStream, ActivityStreamAccess)
|
||||
register_access(CustomInventoryScript, CustomInventoryScriptAccess)
|
||||
register_access(TowerSettings, TowerSettingsAccess)
|
||||
register_access(Notifier, NotifierAccess)
|
||||
register_access(Notification, NotificationAccess)
|
||||
|
||||
@@ -15,7 +15,7 @@ from django.core.management.base import NoArgsCommand
|
||||
# AWX
|
||||
from awx.main.models import * # noqa
|
||||
from awx.main.queue import FifoQueue
|
||||
from awx.main.tasks import handle_work_error
|
||||
from awx.main.tasks import handle_work_error, handle_work_success
|
||||
from awx.main.utils import get_system_task_capacity
|
||||
|
||||
# Celery
|
||||
@@ -265,14 +265,15 @@ def process_graph(graph, task_capacity):
|
||||
[{'type': graph.get_node_type(n['node_object']),
|
||||
'id': n['node_object'].id} for n in node_dependencies]
|
||||
error_handler = handle_work_error.s(subtasks=dependent_nodes)
|
||||
start_status = node_obj.start(error_callback=error_handler)
|
||||
success_handler = handle_work_success.s(task_actual={'type': graph.get_node_type(node_obj),
|
||||
'id': node_obj.id})
|
||||
start_status = node_obj.start(error_callback=error_handler, success_callback=success_handler)
|
||||
if not start_status:
|
||||
node_obj.status = 'failed'
|
||||
if node_obj.job_explanation:
|
||||
node_obj.job_explanation += ' '
|
||||
node_obj.job_explanation += 'Task failed pre-start check.'
|
||||
node_obj.save()
|
||||
# TODO: Run error handler
|
||||
continue
|
||||
remaining_volume -= impact
|
||||
running_impact += impact
|
||||
|
||||
105
awx/main/migrations/0003_v300_changes.py
Normal file
105
awx/main/migrations/0003_v300_changes.py
Normal file
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import jsonfield.fields
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('taggit', '0002_auto_20150616_2121'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('main', '0002_v300_changes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('created', models.DateTimeField(default=None, editable=False)),
|
||||
('modified', models.DateTimeField(default=None, editable=False)),
|
||||
('status', models.CharField(default=b'pending', max_length=20, editable=False, choices=[(b'pending', 'Pending'), (b'successful', 'Successful'), (b'failed', 'Failed')])),
|
||||
('error', models.TextField(default=b'', editable=False, blank=True)),
|
||||
('notifications_sent', models.IntegerField(default=0, editable=False)),
|
||||
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'irc', 'IRC')])),
|
||||
('recipients', models.TextField(default=b'', editable=False, blank=True)),
|
||||
('subject', models.TextField(default=b'', editable=False, blank=True)),
|
||||
('body', jsonfield.fields.JSONField(default=dict, blank=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ('pk',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Notifier',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('created', models.DateTimeField(default=None, editable=False)),
|
||||
('modified', models.DateTimeField(default=None, editable=False)),
|
||||
('description', models.TextField(default=b'', blank=True)),
|
||||
('active', models.BooleanField(default=True, editable=False)),
|
||||
('name', models.CharField(unique=True, max_length=512)),
|
||||
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'irc', 'IRC')])),
|
||||
('notification_configuration', jsonfield.fields.JSONField(default=dict)),
|
||||
('created_by', models.ForeignKey(related_name="{u'class': 'notifier', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),
|
||||
('modified_by', models.ForeignKey(related_name="{u'class': 'notifier', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),
|
||||
('organization', models.ForeignKey(related_name='notifiers', on_delete=django.db.models.deletion.SET_NULL, to='main.Organization', null=True)),
|
||||
('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='notifier',
|
||||
field=models.ForeignKey(related_name='notifications', editable=False, to='main.Notifier'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activitystream',
|
||||
name='notification',
|
||||
field=models.ManyToManyField(to='main.Notification', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='activitystream',
|
||||
name='notifier',
|
||||
field=models.ManyToManyField(to='main.Notifier', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='notifiers_any',
|
||||
field=models.ManyToManyField(related_name='organization_notifiers_for_any', to='main.Notifier', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='notifiers_error',
|
||||
field=models.ManyToManyField(related_name='organization_notifiers_for_errors', to='main.Notifier', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='organization',
|
||||
name='notifiers_success',
|
||||
field=models.ManyToManyField(related_name='organization_notifiers_for_success', to='main.Notifier', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unifiedjob',
|
||||
name='notifications',
|
||||
field=models.ManyToManyField(related_name='unifiedjob_notifications', editable=False, to='main.Notification'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unifiedjobtemplate',
|
||||
name='notifiers_any',
|
||||
field=models.ManyToManyField(related_name='unifiedjobtemplate_notifiers_for_any', to='main.Notifier', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unifiedjobtemplate',
|
||||
name='notifiers_error',
|
||||
field=models.ManyToManyField(related_name='unifiedjobtemplate_notifiers_for_errors', to='main.Notifier', blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='unifiedjobtemplate',
|
||||
name='notifiers_success',
|
||||
field=models.ManyToManyField(related_name='unifiedjobtemplate_notifiers_for_success', to='main.Notifier', blank=True),
|
||||
),
|
||||
]
|
||||
@@ -17,6 +17,7 @@ from awx.main.models.schedules import * # noqa
|
||||
from awx.main.models.activity_stream import * # noqa
|
||||
from awx.main.models.ha import * # noqa
|
||||
from awx.main.models.configuration import * # noqa
|
||||
from awx.main.models.notifications import * # noqa
|
||||
|
||||
# Monkeypatch Django serializer to ignore django-taggit fields (which break
|
||||
# the dumpdata command; see https://github.com/alex/django-taggit/issues/155).
|
||||
@@ -60,3 +61,5 @@ activity_stream_registrar.connect(AdHocCommand)
|
||||
activity_stream_registrar.connect(Schedule)
|
||||
activity_stream_registrar.connect(CustomInventoryScript)
|
||||
activity_stream_registrar.connect(TowerSettings)
|
||||
activity_stream_registrar.connect(Notifier)
|
||||
activity_stream_registrar.connect(Notification)
|
||||
|
||||
@@ -53,6 +53,8 @@ class ActivityStream(models.Model):
|
||||
ad_hoc_command = models.ManyToManyField("AdHocCommand", blank=True)
|
||||
schedule = models.ManyToManyField("Schedule", blank=True)
|
||||
custom_inventory_script = models.ManyToManyField("CustomInventoryScript", blank=True)
|
||||
notifier = models.ManyToManyField("Notifier", blank=True)
|
||||
notification = models.ManyToManyField("Notification", blank=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:activity_stream_detail', args=(self.pk,))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from urlparse import urljoin
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@@ -139,6 +140,9 @@ class AdHocCommand(UnifiedJob):
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:ad_hoc_command_detail', args=(self.pk,))
|
||||
|
||||
def get_ui_url(self):
|
||||
return urljoin(tower_settings.TOWER_URL_BASE, "/#/ad_hoc_commands/{}".format(self.pk))
|
||||
|
||||
@property
|
||||
def task_auth_token(self):
|
||||
'''Return temporary auth token used for task requests via API.'''
|
||||
|
||||
@@ -25,7 +25,7 @@ from awx.main.utils import encrypt_field
|
||||
|
||||
__all__ = ['VarsDictProperty', 'BaseModel', 'CreatedModifiedModel',
|
||||
'PasswordFieldsModel', 'PrimordialModel', 'CommonModel',
|
||||
'CommonModelNameNotUnique',
|
||||
'CommonModelNameNotUnique', 'NotificationFieldsModel',
|
||||
'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ',
|
||||
'PERM_INVENTORY_WRITE', 'PERM_INVENTORY_DEPLOY', 'PERM_INVENTORY_SCAN',
|
||||
'PERM_INVENTORY_CHECK', 'PERM_JOBTEMPLATE_CREATE', 'JOB_TYPE_CHOICES',
|
||||
@@ -337,3 +337,26 @@ class CommonModelNameNotUnique(PrimordialModel):
|
||||
max_length=512,
|
||||
unique=False,
|
||||
)
|
||||
|
||||
class NotificationFieldsModel(BaseModel):
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
notifiers_error = models.ManyToManyField(
|
||||
"Notifier",
|
||||
blank=True,
|
||||
related_name='%(class)s_notifiers_for_errors'
|
||||
)
|
||||
|
||||
notifiers_success = models.ManyToManyField(
|
||||
"Notifier",
|
||||
blank=True,
|
||||
related_name='%(class)s_notifiers_for_success'
|
||||
)
|
||||
|
||||
notifiers_any = models.ManyToManyField(
|
||||
"Notifier",
|
||||
blank=True,
|
||||
related_name='%(class)s_notifiers_for_any'
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import datetime
|
||||
import logging
|
||||
import re
|
||||
import copy
|
||||
from urlparse import urljoin
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@@ -23,7 +24,9 @@ from awx.main.managers import HostManager
|
||||
from awx.main.models.base import * # noqa
|
||||
from awx.main.models.jobs import Job
|
||||
from awx.main.models.unified_jobs import * # noqa
|
||||
from awx.main.models.notifications import Notifier
|
||||
from awx.main.utils import ignore_inventory_computed_fields, _inventory_updates
|
||||
from awx.main.conf import tower_settings
|
||||
|
||||
__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'CustomInventoryScript']
|
||||
|
||||
@@ -1180,6 +1183,14 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def notifiers(self):
|
||||
base_notifiers = Notifier.objects.filter(active=True)
|
||||
error_notifiers = list(base_notifiers.filter(organization_notifiers_for_errors=self.inventory.organization))
|
||||
success_notifiers = list(base_notifiers.filter(organization_notifiers_for_success=self.inventory.organization))
|
||||
any_notifiers = list(base_notifiers.filter(organization_notifiers_for_any=self.inventory.organization))
|
||||
return dict(error=error_notifiers, success=success_notifiers, any=any_notifiers)
|
||||
|
||||
def clean_source(self):
|
||||
source = self.source
|
||||
if source and self.group:
|
||||
@@ -1239,6 +1250,9 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions):
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:inventory_update_detail', args=(self.pk,))
|
||||
|
||||
def get_ui_url(self):
|
||||
return urljoin(tower_settings.TOWER_URL_BASE, "/#/inventory_sync/{}".format(self.pk))
|
||||
|
||||
def is_blocked_by(self, obj):
|
||||
if type(obj) == InventoryUpdate:
|
||||
if self.inventory_source.inventory == obj.inventory_source.inventory:
|
||||
|
||||
@@ -6,6 +6,7 @@ import hmac
|
||||
import json
|
||||
import yaml
|
||||
import logging
|
||||
from urlparse import urljoin
|
||||
|
||||
# Django
|
||||
from django.conf import settings
|
||||
@@ -22,6 +23,7 @@ from jsonfield import JSONField
|
||||
from awx.main.constants import CLOUD_PROVIDERS
|
||||
from awx.main.models.base import * # noqa
|
||||
from awx.main.models.unified_jobs import * # noqa
|
||||
from awx.main.models.notifications import Notifier
|
||||
from awx.main.utils import decrypt_field, ignore_inventory_computed_fields
|
||||
from awx.main.utils import emit_websocket_notification
|
||||
from awx.main.redact import PlainTextCleaner
|
||||
@@ -330,6 +332,20 @@ class JobTemplate(UnifiedJobTemplate, JobOptions):
|
||||
def _can_update(self):
|
||||
return self.can_start_without_user_input()
|
||||
|
||||
@property
|
||||
def notifiers(self):
|
||||
# Return all notifiers defined on the Job Template, on the Project, and on the Organization for each trigger type
|
||||
# TODO: Currently there is no org fk on project so this will need to be added once that is
|
||||
# available after the rbac pr
|
||||
base_notifiers = Notifier.objects.filter(active=True)
|
||||
error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_errors__in=[self, self.project]))
|
||||
success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_success__in=[self, self.project]))
|
||||
any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_any__in=[self, self.project]))
|
||||
# Get Organization Notifiers
|
||||
error_notifiers = set(error_notifiers + list(base_notifiers.filter(organization_notifiers_for_errors__in=self.project.organizations.all())))
|
||||
success_notifiers = set(success_notifiers + list(base_notifiers.filter(organization_notifiers_for_success__in=self.project.organizations.all())))
|
||||
any_notifiers = set(any_notifiers + list(base_notifiers.filter(organization_notifiers_for_any__in=self.project.organizations.all())))
|
||||
return dict(error=list(error_notifiers), success=list(success_notifiers), any=list(any_notifiers))
|
||||
|
||||
class Job(UnifiedJob, JobOptions):
|
||||
'''
|
||||
@@ -369,6 +385,9 @@ class Job(UnifiedJob, JobOptions):
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:job_detail', args=(self.pk,))
|
||||
|
||||
def get_ui_url(self):
|
||||
return urljoin(tower_settings.TOWER_URL_BASE, "/#/jobs/{}".format(self.pk))
|
||||
|
||||
@property
|
||||
def task_auth_token(self):
|
||||
'''Return temporary auth token used for task requests via API.'''
|
||||
@@ -485,6 +504,26 @@ class Job(UnifiedJob, JobOptions):
|
||||
dependencies.append(source.create_inventory_update(launch_type='dependency'))
|
||||
return dependencies
|
||||
|
||||
def notification_data(self):
|
||||
data = super(Job, self).notification_data()
|
||||
all_hosts = {}
|
||||
for h in self.job_host_summaries.all():
|
||||
all_hosts[h.host.name] = dict(failed=h.failed,
|
||||
changed=h.changed,
|
||||
dark=h.dark,
|
||||
failures=h.failures,
|
||||
ok=h.ok,
|
||||
processed=h.processed,
|
||||
skipped=h.skipped)
|
||||
data.update(dict(inventory=self.inventory.name,
|
||||
project=self.project.name,
|
||||
playbook=self.playbook,
|
||||
credential=self.credential.name,
|
||||
limit=self.limit,
|
||||
extra_vars=self.extra_vars,
|
||||
hosts=all_hosts))
|
||||
return data
|
||||
|
||||
def handle_extra_data(self, extra_data):
|
||||
extra_vars = {}
|
||||
if isinstance(extra_data, dict):
|
||||
@@ -1065,6 +1104,9 @@ class SystemJob(UnifiedJob, SystemJobOptions):
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:system_job_detail', args=(self.pk,))
|
||||
|
||||
def get_ui_url(self):
|
||||
return urljoin(tower_settings.TOWER_URL_BASE, "/#/management_jobs/{}".format(self.pk))
|
||||
|
||||
def is_blocked_by(self, obj):
|
||||
return True
|
||||
|
||||
|
||||
172
awx/main/models/notifications.py
Normal file
172
awx/main/models/notifications.py
Normal file
@@ -0,0 +1,172 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.mail.message import EmailMessage
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.encoding import smart_str
|
||||
|
||||
from awx.main.models.base import * # noqa
|
||||
from awx.main.utils import encrypt_field, decrypt_field
|
||||
from awx.main.notifications.email_backend import CustomEmailBackend
|
||||
from awx.main.notifications.slack_backend import SlackBackend
|
||||
from awx.main.notifications.twilio_backend import TwilioBackend
|
||||
from awx.main.notifications.pagerduty_backend import PagerDutyBackend
|
||||
from awx.main.notifications.hipchat_backend import HipChatBackend
|
||||
from awx.main.notifications.webhook_backend import WebhookBackend
|
||||
from awx.main.notifications.irc_backend import IrcBackend
|
||||
|
||||
# Django-JSONField
|
||||
from jsonfield import JSONField
|
||||
|
||||
logger = logging.getLogger('awx.main.models.notifications')
|
||||
|
||||
__all__ = ['Notifier', 'Notification']
|
||||
|
||||
class Notifier(CommonModel):
|
||||
|
||||
NOTIFICATION_TYPES = [('email', _('Email'), CustomEmailBackend),
|
||||
('slack', _('Slack'), SlackBackend),
|
||||
('twilio', _('Twilio'), TwilioBackend),
|
||||
('pagerduty', _('Pagerduty'), PagerDutyBackend),
|
||||
('hipchat', _('HipChat'), HipChatBackend),
|
||||
('webhook', _('Webhook'), WebhookBackend),
|
||||
('irc', _('IRC'), IrcBackend)]
|
||||
NOTIFICATION_TYPE_CHOICES = [(x[0], x[1]) for x in NOTIFICATION_TYPES]
|
||||
CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NOTIFICATION_TYPES])
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
|
||||
organization = models.ForeignKey(
|
||||
'Organization',
|
||||
blank=False,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='notifiers',
|
||||
)
|
||||
|
||||
notification_type = models.CharField(
|
||||
max_length = 32,
|
||||
choices=NOTIFICATION_TYPE_CHOICES,
|
||||
)
|
||||
|
||||
notification_configuration = JSONField(blank=False)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:notifier_detail', args=(self.pk,))
|
||||
|
||||
@property
|
||||
def notification_class(self):
|
||||
return self.CLASS_FOR_NOTIFICATION_TYPE[self.notification_type]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
new_instance = not bool(self.pk)
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
|
||||
self.notification_class.init_parameters):
|
||||
if new_instance:
|
||||
value = self.notification_configuration[field]
|
||||
setattr(self, '_saved_{}_{}'.format("config", field), value)
|
||||
self.notification_configuration[field] = ''
|
||||
else:
|
||||
encrypted = encrypt_field(self, 'notification_configuration', subfield=field)
|
||||
self.notification_configuration[field] = encrypted
|
||||
if 'notification_configuration' not in update_fields:
|
||||
update_fields.append('notification_configuration')
|
||||
super(Notifier, self).save(*args, **kwargs)
|
||||
if new_instance:
|
||||
update_fields = []
|
||||
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
|
||||
self.notification_class.init_parameters):
|
||||
saved_value = getattr(self, '_saved_{}_{}'.format("config", field), '')
|
||||
self.notification_configuration[field] = saved_value
|
||||
#setattr(self.notification_configuration, field, saved_value)
|
||||
if 'notification_configuration' not in update_fields:
|
||||
update_fields.append('notification_configuration')
|
||||
self.save(update_fields=update_fields)
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
return self.notification_configuration[self.notification_class.recipient_parameter]
|
||||
|
||||
def generate_notification(self, subject, message):
|
||||
notification = Notification(notifier=self,
|
||||
notification_type=self.notification_type,
|
||||
recipients=smart_str(self.recipients),
|
||||
subject=subject,
|
||||
body=message)
|
||||
notification.save()
|
||||
return notification
|
||||
|
||||
def send(self, subject, body):
|
||||
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
|
||||
self.notification_class.init_parameters):
|
||||
self.notification_configuration[field] = decrypt_field(self,
|
||||
'notification_configuration',
|
||||
subfield=field)
|
||||
recipients = self.notification_configuration.pop(self.notification_class.recipient_parameter)
|
||||
if not isinstance(recipients, list):
|
||||
recipients = [recipients]
|
||||
sender = self.notification_configuration.pop(self.notification_class.sender_parameter, None)
|
||||
backend_obj = self.notification_class(**self.notification_configuration)
|
||||
notification_obj = EmailMessage(subject, backend_obj.format_body(body), sender, recipients)
|
||||
return backend_obj.send_messages([notification_obj])
|
||||
|
||||
class Notification(CreatedModifiedModel):
|
||||
'''
|
||||
A notification event emitted when a Notifier is run
|
||||
'''
|
||||
|
||||
NOTIFICATION_STATE_CHOICES = [
|
||||
('pending', _('Pending')),
|
||||
('successful', _('Successful')),
|
||||
('failed', _('Failed')),
|
||||
]
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
ordering = ('pk',)
|
||||
|
||||
notifier = models.ForeignKey(
|
||||
'Notifier',
|
||||
related_name='notifications',
|
||||
on_delete=models.CASCADE,
|
||||
editable=False
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=NOTIFICATION_STATE_CHOICES,
|
||||
default='pending',
|
||||
editable=False,
|
||||
)
|
||||
error = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
editable=False,
|
||||
)
|
||||
notifications_sent = models.IntegerField(
|
||||
default=0,
|
||||
editable=False,
|
||||
)
|
||||
notification_type = models.CharField(
|
||||
max_length = 32,
|
||||
choices=Notifier.NOTIFICATION_TYPE_CHOICES,
|
||||
)
|
||||
recipients = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
editable=False,
|
||||
)
|
||||
subject = models.TextField(
|
||||
blank=True,
|
||||
default='',
|
||||
editable=False,
|
||||
)
|
||||
body = JSONField(blank=True)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:notification_detail', args=(self.pk,))
|
||||
@@ -23,7 +23,7 @@ from awx.main.conf import tower_settings
|
||||
__all__ = ['Organization', 'Team', 'Permission', 'Profile', 'AuthToken']
|
||||
|
||||
|
||||
class Organization(CommonModel):
|
||||
class Organization(CommonModel, NotificationFieldsModel):
|
||||
'''
|
||||
An organization is the basic unit of multi-tenancy divisions
|
||||
'''
|
||||
|
||||
@@ -20,8 +20,10 @@ from django.utils.timezone import now, make_aware, get_default_timezone
|
||||
from awx.lib.compat import slugify
|
||||
from awx.main.models.base import * # noqa
|
||||
from awx.main.models.jobs import Job
|
||||
from awx.main.models.notifications import Notifier
|
||||
from awx.main.models.unified_jobs import * # noqa
|
||||
from awx.main.utils import update_scm_url
|
||||
from awx.main.conf import tower_settings
|
||||
|
||||
__all__ = ['Project', 'ProjectUpdate']
|
||||
|
||||
@@ -309,6 +311,18 @@ class Project(UnifiedJobTemplate, ProjectOptions):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def notifiers(self):
|
||||
base_notifiers = Notifier.objects.filter(active=True)
|
||||
error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_errors=self))
|
||||
success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_success=self))
|
||||
any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_any=self))
|
||||
# Get Organization Notifiers
|
||||
error_notifiers = set(error_notifiers + list(base_notifiers.filter(organization_notifiers_for_errors__in=self.organizations.all())))
|
||||
success_notifiers = set(success_notifiers + list(base_notifiers.filter(organization_notifiers_for_success__in=self.organizations.all())))
|
||||
any_notifiers = set(any_notifiers + list(base_notifiers.filter(organization_notifiers_for_any__in=self.organizations.all())))
|
||||
return dict(error=list(error_notifiers), success=list(success_notifiers), any=list(any_notifiers))
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:project_detail', args=(self.pk,))
|
||||
|
||||
@@ -370,6 +384,9 @@ class ProjectUpdate(UnifiedJob, ProjectOptions):
|
||||
def get_absolute_url(self):
|
||||
return reverse('api:project_update_detail', args=(self.pk,))
|
||||
|
||||
def get_ui_url(self):
|
||||
return urlparse.urljoin(tower_settings.TOWER_URL_BASE, "/#/scm_update/{}".format(self.pk))
|
||||
|
||||
def _update_parent_instance(self):
|
||||
parent_instance = self._get_parent_instance()
|
||||
if parent_instance:
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.db import models
|
||||
from django.core.exceptions import NON_FIELD_ERRORS
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.timezone import now
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
# Django-JSONField
|
||||
from jsonfield import JSONField
|
||||
@@ -40,7 +41,7 @@ logger = logging.getLogger('awx.main.models.unified_jobs')
|
||||
CAN_CANCEL = ('new', 'pending', 'waiting', 'running')
|
||||
|
||||
|
||||
class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique):
|
||||
class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, NotificationFieldsModel):
|
||||
'''
|
||||
Concrete base class for unified job templates.
|
||||
'''
|
||||
@@ -297,6 +298,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique):
|
||||
'''
|
||||
return kwargs # Override if needed in subclass.
|
||||
|
||||
@property
|
||||
def notifiers(self):
|
||||
'''
|
||||
Return notifiers relevant to this Unified Job Template
|
||||
'''
|
||||
# NOTE: Derived classes should implement
|
||||
return Notifier.objects.none()
|
||||
|
||||
def create_unified_job(self, **kwargs):
|
||||
'''
|
||||
Create a new unified job based on this unified job template.
|
||||
@@ -385,6 +394,11 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
editable=False,
|
||||
related_name='%(class)s_blocked_jobs+',
|
||||
)
|
||||
notifications = models.ManyToManyField(
|
||||
'Notification',
|
||||
editable=False,
|
||||
related_name='%(class)s_notifications',
|
||||
)
|
||||
cancel_flag = models.BooleanField(
|
||||
blank=True,
|
||||
default=False,
|
||||
@@ -470,6 +484,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
else:
|
||||
return ''
|
||||
|
||||
def get_ui_url(self):
|
||||
real_instance = self.get_real_instance()
|
||||
if real_instance != self:
|
||||
return real_instance.get_ui_url()
|
||||
else:
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def _get_task_class(cls):
|
||||
raise NotImplementedError # Implement in subclasses.
|
||||
@@ -717,7 +738,17 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
tasks that might preclude creating one'''
|
||||
return []
|
||||
|
||||
def start(self, error_callback, **kwargs):
|
||||
def notification_data(self):
|
||||
return dict(id=self.id,
|
||||
name=self.name,
|
||||
url=self.get_ui_url(),
|
||||
created_by=smart_text(self.created_by),
|
||||
started=self.started.isoformat(),
|
||||
finished=self.finished.isoformat(),
|
||||
status=self.status,
|
||||
traceback=self.result_traceback)
|
||||
|
||||
def start(self, error_callback, success_callback, **kwargs):
|
||||
'''
|
||||
Start the task running via Celery.
|
||||
'''
|
||||
@@ -743,7 +774,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
# if field not in needed])
|
||||
if 'extra_vars' in kwargs:
|
||||
self.handle_extra_data(kwargs['extra_vars'])
|
||||
task_class().apply_async((self.pk,), opts, link_error=error_callback)
|
||||
task_class().apply_async((self.pk,), opts, link_error=error_callback, link=success_callback)
|
||||
return True
|
||||
|
||||
def signal_start(self, **kwargs):
|
||||
@@ -765,7 +796,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
|
||||
# Sanity check: If we are running unit tests, then run synchronously.
|
||||
if getattr(settings, 'CELERY_UNIT_TEST', False):
|
||||
return self.start(None, **kwargs)
|
||||
return self.start(None, None, **kwargs)
|
||||
|
||||
# Save the pending status, and inform the SocketIO listener.
|
||||
self.update_fields(start_args=json.dumps(kwargs), status='pending')
|
||||
|
||||
0
awx/main/notifications/__init__.py
Normal file
0
awx/main/notifications/__init__.py
Normal file
20
awx/main/notifications/base.py
Normal file
20
awx/main/notifications/base.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import pprint
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
from django.core.mail.backends.base import BaseEmailBackend
|
||||
|
||||
class TowerBaseEmailBackend(BaseEmailBackend):
|
||||
|
||||
def format_body(self, body):
|
||||
if "body" in body:
|
||||
body_actual = body['body']
|
||||
else:
|
||||
body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'],
|
||||
body['id'],
|
||||
body['status'],
|
||||
body['url']))
|
||||
body_actual += pprint.pformat(body, indent=4)
|
||||
return body_actual
|
||||
28
awx/main/notifications/email_backend.py
Normal file
28
awx/main/notifications/email_backend.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import pprint
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
|
||||
class CustomEmailBackend(EmailBackend):
|
||||
|
||||
init_parameters = {"host": {"label": "Host", "type": "string"},
|
||||
"port": {"label": "Port", "type": "int"},
|
||||
"username": {"label": "Username", "type": "string"},
|
||||
"password": {"label": "Password", "type": "password"},
|
||||
"use_tls": {"label": "Use TLS", "type": "bool"},
|
||||
"use_ssl": {"label": "Use SSL", "type": "bool"},
|
||||
"sender": {"label": "Sender Email", "type": "string"},
|
||||
"recipients": {"label": "Recipient List", "type": "list"}}
|
||||
recipient_parameter = "recipients"
|
||||
sender_parameter = "sender"
|
||||
|
||||
def format_body(self, body):
|
||||
body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'],
|
||||
body['id'],
|
||||
body['status'],
|
||||
body['url']))
|
||||
body_actual += pprint.pformat(body, indent=4)
|
||||
return body_actual
|
||||
49
awx/main/notifications/hipchat_backend.py
Normal file
49
awx/main/notifications/hipchat_backend.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from awx.main.notifications.base import TowerBaseEmailBackend
|
||||
|
||||
logger = logging.getLogger('awx.main.notifications.hipchat_backend')
|
||||
|
||||
class HipChatBackend(TowerBaseEmailBackend):
|
||||
|
||||
init_parameters = {"token": {"label": "Token", "type": "password"},
|
||||
"channels": {"label": "Destination Channels", "type": "list"},
|
||||
"color": {"label": "Notification Color", "type": "string"},
|
||||
"api_url": {"label": "API Url (e.g: https://mycompany.hipchat.com)", "type": "string"},
|
||||
"notify": {"label": "Notify channel", "type": "bool"},
|
||||
"message_from": {"label": "Label to be shown with notification", "type": "string"}}
|
||||
recipient_parameter = "channels"
|
||||
sender_parameter = "message_from"
|
||||
|
||||
def __init__(self, token, color, api_url, notify, fail_silently=False, **kwargs):
|
||||
super(HipChatBackend, self).__init__(fail_silently=fail_silently)
|
||||
self.token = token
|
||||
self.color = color
|
||||
self.api_url = api_url
|
||||
self.notify = notify
|
||||
|
||||
def send_messages(self, messages):
|
||||
sent_messages = 0
|
||||
|
||||
for m in messages:
|
||||
for rcp in m.recipients():
|
||||
r = requests.post("{}/v2/room/{}/notification".format(self.api_url, rcp),
|
||||
params={"auth_token": self.token},
|
||||
json={"color": self.color,
|
||||
"message": m.subject,
|
||||
"notify": self.notify,
|
||||
"from": m.from_email,
|
||||
"message_format": "text"})
|
||||
if r.status_code != 204:
|
||||
logger.error(smart_text("Error sending messages: {}".format(r.text)))
|
||||
if not self.fail_silently:
|
||||
raise Exception(smart_text("Error sending message to hipchat: {}".format(r.text)))
|
||||
sent_messages += 1
|
||||
return sent_messages
|
||||
95
awx/main/notifications/irc_backend.py
Normal file
95
awx/main/notifications/irc_backend.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import time
|
||||
import ssl
|
||||
import logging
|
||||
|
||||
import irc.client
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from awx.main.notifications.base import TowerBaseEmailBackend
|
||||
|
||||
logger = logging.getLogger('awx.main.notifications.irc_backend')
|
||||
|
||||
class IrcBackend(TowerBaseEmailBackend):
|
||||
|
||||
init_parameters = {"server": {"label": "IRC Server Address", "type": "string"},
|
||||
"port": {"label": "IRC Server Port", "type": "int"},
|
||||
"nickname": {"label": "IRC Nick", "type": "string"},
|
||||
"password": {"label": "IRC Server Password", "type": "password"},
|
||||
"use_ssl": {"label": "SSL Connection", "type": "bool"},
|
||||
"targets": {"label": "Destination Channels or Users", "type": "list"}}
|
||||
recipient_parameter = "targets"
|
||||
sender_parameter = None
|
||||
|
||||
def __init__(self, server, port, nickname, password, use_ssl, fail_silently=False, **kwargs):
|
||||
super(IrcBackend, self).__init__(fail_silently=fail_silently)
|
||||
self.server = server
|
||||
self.port = port
|
||||
self.nickname = nickname
|
||||
self.password = password if password != "" else None
|
||||
self.use_ssl = use_ssl
|
||||
self.connection = None
|
||||
|
||||
def open(self):
|
||||
if self.connection is not None:
|
||||
return False
|
||||
if self.use_ssl:
|
||||
connection_factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
|
||||
else:
|
||||
connection_factory = irc.connection.Factory()
|
||||
try:
|
||||
self.reactor = irc.client.Reactor()
|
||||
self.connection = self.reactor.server().connect(
|
||||
self.server,
|
||||
self.port,
|
||||
self.nickname,
|
||||
password=self.password,
|
||||
connect_factory=connection_factory,
|
||||
)
|
||||
except irc.client.ServerConnectionError as e:
|
||||
logger.error(smart_text("Exception connecting to irc server: {}".format(e)))
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
if self.connection is None:
|
||||
return
|
||||
self.connection = None
|
||||
|
||||
def on_connect(self, connection, event):
|
||||
for c in self.channels:
|
||||
if irc.client.is_channel(c):
|
||||
connection.join(c)
|
||||
else:
|
||||
for m in self.channels[c]:
|
||||
connection.privmsg(c, m.subject)
|
||||
self.channels_sent += 1
|
||||
|
||||
def on_join(self, connection, event):
|
||||
for m in self.channels[event.target]:
|
||||
connection.privmsg(event.target, m.subject)
|
||||
self.channels_sent += 1
|
||||
|
||||
def send_messages(self, messages):
|
||||
if self.connection is None:
|
||||
self.open()
|
||||
self.channels = {}
|
||||
self.channels_sent = 0
|
||||
for m in messages:
|
||||
for r in m.recipients():
|
||||
if r not in self.channels:
|
||||
self.channels[r] = []
|
||||
self.channels[r].append(m)
|
||||
self.connection.add_global_handler("welcome", self.on_connect)
|
||||
self.connection.add_global_handler("join", self.on_join)
|
||||
start_time = time.time()
|
||||
process_time = time.time()
|
||||
while self.channels_sent < len(self.channels) and (process_time - start_time) < 60:
|
||||
self.reactor.process_once(0.1)
|
||||
process_time = time.time()
|
||||
self.reactor.disconnect_all()
|
||||
return self.channels_sent
|
||||
49
awx/main/notifications/pagerduty_backend.py
Normal file
49
awx/main/notifications/pagerduty_backend.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
import pygerduty
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from awx.main.notifications.base import TowerBaseEmailBackend
|
||||
|
||||
logger = logging.getLogger('awx.main.notifications.pagerduty_backend')
|
||||
|
||||
class PagerDutyBackend(TowerBaseEmailBackend):
|
||||
|
||||
init_parameters = {"subdomain": {"label": "Pagerduty subdomain", "type": "string"},
|
||||
"token": {"label": "API Token", "type": "password"},
|
||||
"service_key": {"label": "API Service/Integration Key", "type": "string"},
|
||||
"client_name": {"label": "Client Identifier", "type": "string"}}
|
||||
recipient_parameter = "service_key"
|
||||
sender_parameter = "client_name"
|
||||
|
||||
def __init__(self, subdomain, token, fail_silently=False, **kwargs):
|
||||
super(PagerDutyBackend, self).__init__(fail_silently=fail_silently)
|
||||
self.subdomain = subdomain
|
||||
self.token = token
|
||||
|
||||
def format_body(self, body):
|
||||
return body
|
||||
|
||||
def send_messages(self, messages):
|
||||
sent_messages = 0
|
||||
|
||||
try:
|
||||
pager = pygerduty.PagerDuty(self.subdomain, self.token)
|
||||
except Exception as e:
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
logger.error(smart_text("Exception connecting to PagerDuty: {}".format(e)))
|
||||
for m in messages:
|
||||
try:
|
||||
pager.trigger_incident(m.recipients()[0],
|
||||
description=m.subject,
|
||||
details=m.body,
|
||||
client=m.from_email)
|
||||
except Exception as e:
|
||||
logger.error(smart_text("Exception sending messages: {}".format(e)))
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
return sent_messages
|
||||
52
awx/main/notifications/slack_backend.py
Normal file
52
awx/main/notifications/slack_backend.py
Normal file
@@ -0,0 +1,52 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
from slackclient import SlackClient
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from awx.main.notifications.base import TowerBaseEmailBackend
|
||||
|
||||
logger = logging.getLogger('awx.main.notifications.slack_backend')
|
||||
|
||||
class SlackBackend(TowerBaseEmailBackend):
|
||||
|
||||
init_parameters = {"token": {"label": "Token", "type": "password"},
|
||||
"channels": {"label": "Destination Channels", "type": "list"}}
|
||||
recipient_parameter = "channels"
|
||||
sender_parameter = None
|
||||
|
||||
def __init__(self, token, fail_silently=False, **kwargs):
|
||||
super(SlackBackend, self).__init__(fail_silently=fail_silently)
|
||||
self.token = token
|
||||
self.connection = None
|
||||
|
||||
def open(self):
|
||||
if self.connection is not None:
|
||||
return False
|
||||
self.connection = SlackClient(self.token)
|
||||
if not self.connection.rtm_connect():
|
||||
if not self.fail_silently:
|
||||
raise Exception("Slack Notification Token is invalid")
|
||||
return True
|
||||
|
||||
def close(self):
|
||||
if self.connection is None:
|
||||
return
|
||||
self.connection = None
|
||||
|
||||
def send_messages(self, messages):
|
||||
if self.connection is None:
|
||||
self.open()
|
||||
sent_messages = 0
|
||||
for m in messages:
|
||||
try:
|
||||
for r in m.recipients():
|
||||
self.connection.rtm_send_message(r, m.subject)
|
||||
sent_messages += 1
|
||||
except Exception as e:
|
||||
logger.error(smart_text("Exception sending messages: {}".format(e)))
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
return sent_messages
|
||||
48
awx/main/notifications/twilio_backend.py
Normal file
48
awx/main/notifications/twilio_backend.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
|
||||
from twilio.rest import TwilioRestClient
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from awx.main.notifications.base import TowerBaseEmailBackend
|
||||
|
||||
logger = logging.getLogger('awx.main.notifications.twilio_backend')
|
||||
|
||||
class TwilioBackend(TowerBaseEmailBackend):
|
||||
|
||||
init_parameters = {"account_sid": {"label": "Account SID", "type": "string"},
|
||||
"account_token": {"label": "Account Token", "type": "password"},
|
||||
"from_number": {"label": "Source Phone Number", "type": "string"},
|
||||
"to_numbers": {"label": "Destination SMS Numbers", "type": "list"}}
|
||||
recipient_parameter = "to_numbers"
|
||||
sender_parameter = "from_number"
|
||||
|
||||
def __init__(self, account_sid, account_token, fail_silently=False, **kwargs):
|
||||
super(TwilioBackend, self).__init__(fail_silently=fail_silently)
|
||||
self.account_sid = account_sid
|
||||
self.account_token = account_token
|
||||
|
||||
def send_messages(self, messages):
|
||||
sent_messages = 0
|
||||
try:
|
||||
connection = TwilioRestClient(self.account_sid, self.account_token)
|
||||
except Exception as e:
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
logger.error(smart_text("Exception connecting to Twilio: {}".format(e)))
|
||||
|
||||
for m in messages:
|
||||
try:
|
||||
connection.messages.create(
|
||||
to=m.to,
|
||||
from_=m.from_email,
|
||||
body=m.subject)
|
||||
sent_messages += 1
|
||||
except Exception as e:
|
||||
logger.error(smart_text("Exception sending messages: {}".format(e)))
|
||||
if not self.fail_silently:
|
||||
raise
|
||||
return sent_messages
|
||||
39
awx/main/notifications/webhook_backend.py
Normal file
39
awx/main/notifications/webhook_backend.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Copyright (c) 2016 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
|
||||
from django.utils.encoding import smart_text
|
||||
|
||||
from awx.main.notifications.base import TowerBaseEmailBackend
|
||||
|
||||
logger = logging.getLogger('awx.main.notifications.webhook_backend')
|
||||
|
||||
class WebhookBackend(TowerBaseEmailBackend):
|
||||
|
||||
init_parameters = {"url": {"label": "Target URL", "type": "string"},
|
||||
"headers": {"label": "HTTP Headers", "type": "object"}}
|
||||
recipient_parameter = "url"
|
||||
sender_parameter = None
|
||||
|
||||
def __init__(self, headers, fail_silently=False, **kwargs):
|
||||
self.headers = headers
|
||||
super(WebhookBackend, self).__init__(fail_silently=fail_silently)
|
||||
|
||||
def format_body(self, body):
|
||||
return body
|
||||
|
||||
def send_messages(self, messages):
|
||||
sent_messages = 0
|
||||
for m in messages:
|
||||
r = requests.post("{}".format(m.recipients()[0]),
|
||||
data=json.dumps(m.body),
|
||||
headers=self.headers)
|
||||
if r.status_code >= 400:
|
||||
logger.error(smart_text("Error sending notification webhook: {}".format(r.text)))
|
||||
if not self.fail_silently:
|
||||
raise Exception(smart_text("Error sending notification webhook: {}".format(r.text)))
|
||||
sent_messages += 1
|
||||
return sent_messages
|
||||
@@ -307,6 +307,8 @@ model_serializer_mapping = {
|
||||
Job: JobSerializer,
|
||||
AdHocCommand: AdHocCommandSerializer,
|
||||
TowerSettings: TowerSettingsSerializer,
|
||||
Notifier: NotifierSerializer,
|
||||
Notification: NotificationSerializer,
|
||||
}
|
||||
|
||||
def activity_stream_create(sender, instance, created, **kwargs):
|
||||
|
||||
@@ -39,6 +39,9 @@ from celery import Task, task
|
||||
from django.conf import settings
|
||||
from django.db import transaction, DatabaseError
|
||||
from django.utils.timezone import now
|
||||
from django.utils.encoding import smart_text
|
||||
from django.core.mail import send_mail
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
# AWX
|
||||
from awx.lib.metrics import task_timer
|
||||
@@ -46,13 +49,15 @@ from awx.main.constants import CLOUD_PROVIDERS
|
||||
from awx.main.models import * # noqa
|
||||
from awx.main.queue import FifoQueue
|
||||
from awx.main.conf import tower_settings
|
||||
from awx.main.task_engine import TaskSerializer, TASK_TIMEOUT_INTERVAL
|
||||
from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url,
|
||||
ignore_inventory_computed_fields, emit_websocket_notification,
|
||||
check_proot_installed, build_proot_temp_dir, wrap_args_with_proot)
|
||||
from awx.fact.utils.connection import test_mongo_connection
|
||||
|
||||
__all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate',
|
||||
'RunAdHocCommand', 'handle_work_error', 'update_inventory_computed_fields']
|
||||
'RunAdHocCommand', 'handle_work_error', 'handle_work_success',
|
||||
'update_inventory_computed_fields', 'send_notifications', 'run_administrative_checks']
|
||||
|
||||
HIDDEN_PASSWORD = '**********'
|
||||
|
||||
@@ -64,6 +69,48 @@ Try upgrading OpenSSH or providing your private key in an different format. \
|
||||
|
||||
logger = logging.getLogger('awx.main.tasks')
|
||||
|
||||
@task()
|
||||
def send_notifications(notification_list, job_id=None):
|
||||
if not isinstance(notification_list, list):
|
||||
raise TypeError("notification_list should be of type list")
|
||||
if job_id is not None:
|
||||
job_actual = UnifiedJob.objects.get(id=job_id)
|
||||
for notification_id in notification_list:
|
||||
notification = Notification.objects.get(id=notification_id)
|
||||
try:
|
||||
sent = notification.notifier.send(notification.subject, notification.body)
|
||||
notification.status = "successful"
|
||||
notification.notifications_sent = sent
|
||||
except Exception as e:
|
||||
logger.error("Send Notification Failed {}".format(e))
|
||||
notification.status = "failed"
|
||||
notification.error = smart_text(e)
|
||||
finally:
|
||||
notification.save()
|
||||
if job_id is not None:
|
||||
job_actual.notifications.add(notification)
|
||||
|
||||
@task(bind=True)
|
||||
def run_administrative_checks(self):
|
||||
if not tower_settings.TOWER_ADMIN_ALERTS:
|
||||
return
|
||||
reader = TaskSerializer()
|
||||
validation_info = reader.from_database()
|
||||
if validation_info.get('instance_count', 0) < 1:
|
||||
return
|
||||
used_percentage = float(validation_info.get('current_instances', 0)) / float(validation_info.get('instance_count', 100))
|
||||
tower_admin_emails = User.objects.filter(is_superuser=True).values_list('email', flat=True)
|
||||
if (used_percentage * 100) > 90:
|
||||
send_mail("Ansible Tower host usage over 90%",
|
||||
"Ansible Tower host usage over 90%",
|
||||
tower_admin_emails,
|
||||
fail_silently=True)
|
||||
if validation_info.get('time_remaining', 0) < TASK_TIMEOUT_INTERVAL:
|
||||
send_mail("Ansible Tower license will expire soon",
|
||||
"Ansible Tower license will expire soon",
|
||||
tower_admin_emails,
|
||||
fail_silently=True)
|
||||
|
||||
@task()
|
||||
def bulk_inventory_element_delete(inventory, hosts=[], groups=[]):
|
||||
from awx.main.signals import disable_activity_stream
|
||||
@@ -134,7 +181,6 @@ def notify_task_runner(metadata_dict):
|
||||
queue = FifoQueue('tower_task_manager')
|
||||
queue.push(metadata_dict)
|
||||
|
||||
|
||||
@task()
|
||||
def mongodb_control(cmd):
|
||||
# Sanity check: Do not send arbitrary commands.
|
||||
@@ -159,6 +205,39 @@ def mongodb_control(cmd):
|
||||
p = subprocess.Popen('sudo mongod --shutdown -f /etc/mongod.conf', shell=True)
|
||||
p.wait()
|
||||
|
||||
@task(bind=True)
|
||||
def handle_work_success(self, result, task_actual):
|
||||
if task_actual['type'] == 'project_update':
|
||||
instance = ProjectUpdate.objects.get(id=task_actual['id'])
|
||||
instance_name = instance.name
|
||||
notifiers = instance.project.notifiers
|
||||
friendly_name = "Project Update"
|
||||
elif task_actual['type'] == 'inventory_update':
|
||||
instance = InventoryUpdate.objects.get(id=task_actual['id'])
|
||||
instance_name = instance.name
|
||||
notifiers = instance.inventory_source.notifiers
|
||||
friendly_name = "Inventory Update"
|
||||
elif task_actual['type'] == 'job':
|
||||
instance = Job.objects.get(id=task_actual['id'])
|
||||
instance_name = instance.job_template.name
|
||||
notifiers = instance.job_template.notifiers
|
||||
friendly_name = "Job"
|
||||
elif task_actual['type'] == 'ad_hoc_command':
|
||||
instance = AdHocCommand.objects.get(id=task_actual['id'])
|
||||
instance_name = instance.module_name
|
||||
notifiers = [] # TODO: Ad-hoc commands need to notify someone
|
||||
friendly_name = "AdHoc Command"
|
||||
else:
|
||||
return
|
||||
notification_body = instance.notification_data()
|
||||
notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name,
|
||||
task_actual['id'],
|
||||
instance_name,
|
||||
notification_body['url'])
|
||||
send_notifications.delay([n.generate_notification(notification_subject, notification_body)
|
||||
for n in set(notifiers.get('success', []) + notifiers.get('any', []))],
|
||||
job_id=task_actual['id'])
|
||||
|
||||
@task(bind=True)
|
||||
def handle_work_error(self, task_id, subtasks=None):
|
||||
print('Executing error task id %s, subtasks: %s' %
|
||||
@@ -173,15 +252,23 @@ def handle_work_error(self, task_id, subtasks=None):
|
||||
if each_task['type'] == 'project_update':
|
||||
instance = ProjectUpdate.objects.get(id=each_task['id'])
|
||||
instance_name = instance.name
|
||||
notifiers = instance.project.notifiers
|
||||
friendly_name = "Project Update"
|
||||
elif each_task['type'] == 'inventory_update':
|
||||
instance = InventoryUpdate.objects.get(id=each_task['id'])
|
||||
instance_name = instance.name
|
||||
notifiers = instance.inventory_source.notifiers
|
||||
friendly_name = "Inventory Update"
|
||||
elif each_task['type'] == 'job':
|
||||
instance = Job.objects.get(id=each_task['id'])
|
||||
instance_name = instance.job_template.name
|
||||
notifiers = instance.job_template.notifiers
|
||||
friendly_name = "Job"
|
||||
elif each_task['type'] == 'ad_hoc_command':
|
||||
instance = AdHocCommand.objects.get(id=each_task['id'])
|
||||
instance_name = instance.module_name
|
||||
notifiers = []
|
||||
friendly_name = "AdHoc Command"
|
||||
else:
|
||||
# Unknown task type
|
||||
break
|
||||
@@ -190,6 +277,7 @@ def handle_work_error(self, task_id, subtasks=None):
|
||||
first_task_id = instance.id
|
||||
first_task_type = each_task['type']
|
||||
first_task_name = instance_name
|
||||
first_task_friendly_name = friendly_name
|
||||
if instance.celery_task_id != task_id:
|
||||
instance.status = 'failed'
|
||||
instance.failed = True
|
||||
@@ -197,6 +285,16 @@ def handle_work_error(self, task_id, subtasks=None):
|
||||
(first_task_type, first_task_name, first_task_id)
|
||||
instance.save()
|
||||
instance.socketio_emit_status("failed")
|
||||
notification_body = first_task.notification_data()
|
||||
notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name,
|
||||
first_task_id,
|
||||
first_task_name,
|
||||
notification_body['url'])
|
||||
notification_body['friendly_name'] = first_task_friendly_name
|
||||
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
|
||||
for n in set(notifiers.get('error', []) + notifiers.get('any', []))],
|
||||
job_id=first_task_id)
|
||||
|
||||
|
||||
@task()
|
||||
def update_inventory_computed_fields(inventory_id, should_update_hosts=True):
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import pytest
|
||||
import mock
|
||||
|
||||
from django.core.urlresolvers import resolve
|
||||
from django.utils.six.moves.urllib.parse import urlparse
|
||||
|
||||
from awx.main.models.organization import Organization
|
||||
from awx.main.models.projects import Project
|
||||
from awx.main.models.ha import Instance
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.test import (
|
||||
@@ -148,3 +150,11 @@ def instance(settings):
|
||||
@pytest.fixture
|
||||
def organization(instance):
|
||||
return Organization.objects.create(name="test-org", description="test-org-desc")
|
||||
|
||||
@pytest.fixture
|
||||
@mock.patch.object(Project, "update", lambda self, **kwargs: None)
|
||||
def project(instance):
|
||||
return Project.objects.create(name="test-proj",
|
||||
description="test-proj-desc",
|
||||
scm_type="git",
|
||||
scm_url="https://github.com/jlaska/ansible-playbooks")
|
||||
|
||||
118
awx/main/tests/functional/test_notifications.py
Normal file
118
awx/main/tests/functional/test_notifications.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import mock
|
||||
import pytest
|
||||
|
||||
from awx.main.models.notifications import Notification, Notifier
|
||||
from awx.main.models.inventory import Inventory, Group
|
||||
from awx.main.models.organization import Organization
|
||||
from awx.main.models.projects import Project
|
||||
from awx.main.models.jobs import JobTemplate
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.mail.message import EmailMessage
|
||||
|
||||
@pytest.fixture
|
||||
def notifier():
|
||||
return Notifier.objects.create(name="test-notification",
|
||||
notification_type="webhook",
|
||||
notification_configuration=dict(url="http://localhost",
|
||||
headers={"Test": "Header"}))
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_notifier_list(get, user, notifier):
|
||||
url = reverse('api:notifier_list')
|
||||
response = get(url, user('admin', True))
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['results']) == 1
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_basic_parameterization(get, post, user, organization):
|
||||
u = user('admin-poster', True)
|
||||
url = reverse('api:notifier_list')
|
||||
response = post(url,
|
||||
dict(name="test-webhook",
|
||||
description="test webhook",
|
||||
organization=1,
|
||||
notification_type="webhook",
|
||||
notification_configuration=dict(url="http://localhost",
|
||||
headers={"Test": "Header"})),
|
||||
u)
|
||||
assert response.status_code == 201
|
||||
url = reverse('api:notifier_detail', args=(response.data['id'],))
|
||||
response = get(url, u)
|
||||
assert 'related' in response.data
|
||||
assert 'organization' in response.data['related']
|
||||
assert 'summary_fields' in response.data
|
||||
assert 'organization' in response.data['summary_fields']
|
||||
assert 'notifications' in response.data['related']
|
||||
assert 'notification_configuration' in response.data
|
||||
assert 'url' in response.data['notification_configuration']
|
||||
assert 'headers' in response.data['notification_configuration']
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_encrypted_subfields(get, post, user, organization):
|
||||
def assert_send(self, messages):
|
||||
assert self.account_token == "shouldhide"
|
||||
return 1
|
||||
u = user('admin-poster', True)
|
||||
url = reverse('api:notifier_list')
|
||||
response = post(url,
|
||||
dict(name="test-twilio",
|
||||
description="test twilio",
|
||||
organization=organization.id,
|
||||
notification_type="twilio",
|
||||
notification_configuration=dict(account_sid="dummy",
|
||||
account_token="shouldhide",
|
||||
from_number="+19999999999",
|
||||
to_numbers=["9998887777"])),
|
||||
u)
|
||||
assert response.status_code == 201
|
||||
notifier_actual = Notifier.objects.get(id=response.data['id'])
|
||||
url = reverse('api:notifier_detail', args=(response.data['id'],))
|
||||
response = get(url, u)
|
||||
assert response.data['notification_configuration']['account_token'] == "$encrypted$"
|
||||
with mock.patch.object(notifier_actual.notification_class, "send_messages", assert_send):
|
||||
notifier_actual.send("Test", {'body': "Test"})
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_inherited_notifiers(get, post, user, organization, project):
|
||||
u = user('admin-poster', True)
|
||||
url = reverse('api:notifier_list')
|
||||
notifiers = []
|
||||
for nfiers in xrange(3):
|
||||
response = post(url,
|
||||
dict(name="test-webhook-{}".format(nfiers),
|
||||
description="test webhook {}".format(nfiers),
|
||||
organization=1,
|
||||
notification_type="webhook",
|
||||
notification_configuration=dict(url="http://localhost",
|
||||
headers={"Test": "Header"})),
|
||||
u)
|
||||
assert response.status_code == 201
|
||||
notifiers.append(response.data['id'])
|
||||
organization.projects.add(project)
|
||||
i = Inventory.objects.create(name='test', organization=organization)
|
||||
i.save()
|
||||
g = Group.objects.create(name='test', inventory=i)
|
||||
g.save()
|
||||
jt = JobTemplate.objects.create(name='test', inventory=i, project=project, playbook='debug.yml')
|
||||
jt.save()
|
||||
url = reverse('api:organization_notifiers_any_list', args=(organization.id,))
|
||||
response = post(url, dict(id=notifiers[0]), u)
|
||||
assert response.status_code == 204
|
||||
url = reverse('api:project_notifiers_any_list', args=(project.id,))
|
||||
response = post(url, dict(id=notifiers[1]), u)
|
||||
assert response.status_code == 204
|
||||
url = reverse('api:job_template_notifiers_any_list', args=(jt.id,))
|
||||
response = post(url, dict(id=notifiers[2]), u)
|
||||
assert response.status_code == 204
|
||||
assert len(jt.notifiers['any']) == 3
|
||||
assert len(project.notifiers['any']) == 2
|
||||
assert len(g.inventory_source.notifiers['any']) == 1
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_notifier_merging(get, post, user, organization, project, notifier):
|
||||
u = user('admin-poster', True)
|
||||
organization.projects.add(project)
|
||||
organization.notifiers_any.add(notifier)
|
||||
project.notifiers_any.add(notifier)
|
||||
assert len(project.notifiers['any']) == 1
|
||||
@@ -139,12 +139,13 @@ def get_encryption_key(instance, field_name):
|
||||
h.update(field_name)
|
||||
return h.digest()[:16]
|
||||
|
||||
|
||||
def encrypt_field(instance, field_name, ask=False):
|
||||
def encrypt_field(instance, field_name, ask=False, subfield=None):
|
||||
'''
|
||||
Return content of the given instance and field name encrypted.
|
||||
'''
|
||||
value = getattr(instance, field_name)
|
||||
if isinstance(value, dict) and subfield is not None:
|
||||
value = value[subfield]
|
||||
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
|
||||
return value
|
||||
value = smart_str(value)
|
||||
@@ -157,11 +158,13 @@ def encrypt_field(instance, field_name, ask=False):
|
||||
return '$encrypted$%s$%s' % ('AES', b64data)
|
||||
|
||||
|
||||
def decrypt_field(instance, field_name):
|
||||
def decrypt_field(instance, field_name, subfield=None):
|
||||
'''
|
||||
Return content of the given instance and field name decrypted.
|
||||
'''
|
||||
value = getattr(instance, field_name)
|
||||
if isinstance(value, dict) and subfield is not None:
|
||||
value = value[subfield]
|
||||
if not value or not value.startswith('$encrypted$'):
|
||||
return value
|
||||
algo, b64data = value[len('$encrypted$'):].split('$', 1)
|
||||
|
||||
@@ -342,6 +342,10 @@ CELERYBEAT_SCHEDULE = {
|
||||
'task': 'awx.main.tasks.tower_periodic_scheduler',
|
||||
'schedule': timedelta(seconds=30)
|
||||
},
|
||||
'admin_checks': {
|
||||
'task': 'awx.main.tasks.run_administrative_checks',
|
||||
'schedule': timedelta(days=30)
|
||||
},
|
||||
}
|
||||
|
||||
# Social Auth configuration.
|
||||
@@ -677,6 +681,10 @@ FACT_CACHE_PORT = 6564
|
||||
|
||||
ORG_ADMINS_CAN_SEE_ALL_USERS = True
|
||||
|
||||
TOWER_ADMIN_ALERTS = True
|
||||
|
||||
TOWER_URL_BASE = "https://towerhost"
|
||||
|
||||
TOWER_SETTINGS_MANIFEST = {
|
||||
"SCHEDULE_MAX_JOBS": {
|
||||
"name": "Maximum Scheduled Jobs",
|
||||
@@ -804,6 +812,20 @@ TOWER_SETTINGS_MANIFEST = {
|
||||
"type": "bool",
|
||||
"category": "system",
|
||||
},
|
||||
"TOWER_ADMIN_ALERTS": {
|
||||
"name": "Enable Tower Administrator Alerts",
|
||||
"description": "Allow Tower to email Admin users for system events that may require attention",
|
||||
"default": TOWER_ADMIN_ALERTS,
|
||||
"type": "bool",
|
||||
"category": "system",
|
||||
},
|
||||
"TOWER_URL_BASE": {
|
||||
"name": "Base URL of the Tower host",
|
||||
"description": "This is used by services like Notifications to render a valid url to the Tower host",
|
||||
"default": TOWER_URL_BASE,
|
||||
"type": "string",
|
||||
"category": "system",
|
||||
},
|
||||
"LICENSE": {
|
||||
"name": "Tower License",
|
||||
"description": "Controls what features and functionality is enabled in Tower.",
|
||||
|
||||
Reference in New Issue
Block a user