diff --git a/awx/api/serializers.py b/awx/api/serializers.py index b513af2c68..7bc25f532d 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -457,6 +457,8 @@ class BaseSerializer(serializers.ModelSerializer): ret.pop(parent_key, None) return ret +class EmptySerializer(serializers.Serializer): + pass class BaseFactSerializer(DocumentSerializer): @@ -765,7 +767,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_notifications_any_list', args=(obj.pk,)), + notifiers_success = reverse('api:organization_notifications_success_list', args=(obj.pk,)), + notifiers_error = reverse('api:organization_notifications_error_list', args=(obj.pk,)), )) return res @@ -845,6 +851,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_notifications_any_list', args=(obj.pk,)), + notifiers_success = reverse('api:project_notifications_success_list', args=(obj.pk,)), + notifiers_error = reverse('api:project_notifications_error_list', args=(obj.pk,)), )) # Backwards compatibility. if obj.current_update: @@ -888,6 +897,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 @@ -1288,6 +1298,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_notifications_any_list', args=(obj.pk,)), + notifiers_success = reverse('api:inventory_source_notifications_success_list', args=(obj.pk,)), + notifiers_error = reverse('api:inventory_source_notifications_error_list', args=(obj.pk,)), )) if obj.inventory and obj.inventory.active: res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,)) @@ -1332,6 +1345,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 @@ -1550,6 +1564,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_notifications_any_list', args=(obj.pk,)), + notifiers_success = reverse('api:job_template_notifications_success_list', args=(obj.pk,)), + notifiers_error = reverse('api:job_template_notifications_error_list', args=(obj.pk,)), )) if obj.host_config_key: res['callback'] = reverse('api:job_template_callback', args=(obj.pk,)) @@ -1604,6 +1621,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', @@ -2039,7 +2057,15 @@ class NotificationTemplateSerializer(BaseSerializer): class Meta: model = NotificationTemplate - fields = ('*', 'notification_type', 'notification_configuration') + fields = ('*', 'organization', 'notification_type', 'notification_configuration') + + def get_related(self, obj): + res = super(NotificationTemplateSerializer, self).get_related(obj) + res.update(dict( + test = reverse('api:notification_template_test', args=(obj.pk,)), + notifications = reverse('api:notification_template_notification_list', args=(obj.pk,)), + )) + return res def validate(self, attrs): notification_class = NotificationTemplate.CLASS_FOR_NOTIFICATION_TYPE[attrs['notification_type']] @@ -2047,10 +2073,25 @@ class NotificationTemplateSerializer(BaseSerializer): for field in notification_class.init_parameters: if field not in attrs['notification_configuration']: missing_fields.append(field) + # TODO: Type checks if missing_fields: raise serializers.ValidationError("Missing required fields for Notification Configuration: {}".format(missing_fields)) return attrs +class NotificationSerializer(BaseSerializer): + + class Meta: + model = Notification + fields = ('*', '-name', '-description', 'notifier', 'error', 'status', 'notifications_sent', + 'notification_type', 'recipients', 'subject', 'body') + + def get_related(self, obj): + res = super(NotificationSerializer, self).get_related(obj) + res.update(dict( + notification_template = reverse('api:notification_template_detail', args=(obj.notifier.pk,)), + )) + return res + class ScheduleSerializer(BaseSerializer): class Meta: diff --git a/awx/api/urls.py b/awx/api/urls.py index 8e48250560..7e55e46a5c 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -20,6 +20,10 @@ organization_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/projects/$', 'organization_projects_list'), url(r'^(?P[0-9]+)/teams/$', 'organization_teams_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'organization_activity_stream_list'), + url(r'^(?P[0-9]+)/notifiers/$', 'organization_notifiers_list'), + url(r'^(?P[0-9]+)/notifications_any/$', 'organization_notifications_any_list'), + url(r'^(?P[0-9]+)/notifications_error/$', 'organization_notifications_error_list'), + url(r'^(?P[0-9]+)/notifications_success/$', 'organization_notifications_success_list'), ) user_urls = patterns('awx.api.views', @@ -44,12 +48,16 @@ project_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/project_updates/$', 'project_updates_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'project_activity_stream_list'), url(r'^(?P[0-9]+)/schedules/$', 'project_schedules_list'), + url(r'^(?P[0-9]+)/notifications_any/$', 'project_notifications_any_list'), + url(r'^(?P[0-9]+)/notifications_error/$', 'project_notifications_error_list'), + url(r'^(?P[0-9]+)/notifications_success/$', 'project_notifications_success_list'), ) project_update_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/$', 'project_update_detail'), url(r'^(?P[0-9]+)/cancel/$', 'project_update_cancel'), url(r'^(?P[0-9]+)/stdout/$', 'project_update_stdout'), + url(r'^(?P[0-9]+)/notifications/$', 'project_update_notifications_list'), ) team_urls = patterns('awx.api.views', @@ -120,12 +128,16 @@ inventory_source_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/schedules/$', 'inventory_source_schedules_list'), url(r'^(?P[0-9]+)/groups/$', 'inventory_source_groups_list'), url(r'^(?P[0-9]+)/hosts/$', 'inventory_source_hosts_list'), + url(r'^(?P[0-9]+)/notifications_any/$', 'inventory_source_notifications_any_list'), + url(r'^(?P[0-9]+)/notifications_error/$', 'inventory_source_notifications_error_list'), + url(r'^(?P[0-9]+)/notifications_success/$', 'inventory_source_notifications_success_list'), ) inventory_update_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/$', 'inventory_update_detail'), url(r'^(?P[0-9]+)/cancel/$', 'inventory_update_cancel'), url(r'^(?P[0-9]+)/stdout/$', 'inventory_update_stdout'), + url(r'^(?P[0-9]+)/notifications/$', 'inventory_update_notifications_list'), ) inventory_script_urls = patterns('awx.api.views', @@ -153,6 +165,9 @@ job_template_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/schedules/$', 'job_template_schedules_list'), url(r'^(?P[0-9]+)/survey_spec/$', 'job_template_survey_spec'), url(r'^(?P[0-9]+)/activity_stream/$', 'job_template_activity_stream_list'), + url(r'^(?P[0-9]+)/notifications_any/$', 'job_template_notifications_any_list'), + url(r'^(?P[0-9]+)/notifications_error/$', 'job_template_notifications_error_list'), + url(r'^(?P[0-9]+)/notifications_success/$', 'job_template_notifications_success_list'), ) job_urls = patterns('awx.api.views', @@ -167,6 +182,7 @@ job_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/job_tasks/$', 'job_job_tasks_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'job_activity_stream_list'), url(r'^(?P[0-9]+)/stdout/$', 'job_stdout'), + url(r'^(?P[0-9]+)/notifications/$', 'job_notifications_list'), ) job_host_summary_urls = patterns('awx.api.views', @@ -212,6 +228,13 @@ system_job_urls = patterns('awx.api.views', notification_template_urls = patterns('awx.api.views', url(r'^$', 'notification_template_list'), url(r'^(?P[0-9]+)/$', 'notification_template_detail'), + url(r'^(?P[0-9]+)/test/$', 'notification_template_test'), + url(r'^(?P[0-9]+)/notifications/$', 'notification_template_notification_list'), +) + +notification_urls = patterns('awx.api.views', + url(r'^$', 'notification_list'), + url(r'^(?P[0-9]+)/$', 'notification_detail'), ) schedule_urls = patterns('awx.api.views', @@ -263,6 +286,7 @@ v1_urls = patterns('awx.api.views', url(r'^system_job_templates/', include(system_job_template_urls)), url(r'^system_jobs/', include(system_job_urls)), url(r'^notification_templates/', include(notification_template_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)), diff --git a/awx/api/views.py b/awx/api/views.py index 72e1fb606e..be1bd3b609 100644 --- a/awx/api/views.py +++ b/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 @@ -136,6 +136,7 @@ class ApiV1RootView(APIView): data['system_jobs'] = reverse('api:system_job_list') data['schedules'] = reverse('api:schedule_list') data['notification_templates'] = reverse('api:notification_template_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') @@ -684,6 +685,35 @@ class OrganizationActivityStreamList(SubListAPIView): # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) +class OrganizationNotifiersList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = Organization + relationship = 'notification_templates' + parent_key = 'organization' + +class OrganizationNotificationsAnyList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = Organization + relationship = 'notification_any' + +class OrganizationNotificationsErrorList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = Organization + relationship = 'notification_erros' + +class OrganizationNotificationsSuccessList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = Organization + relationship = 'notification_success' + class TeamList(ListCreateAPIView): model = Team @@ -849,6 +879,26 @@ class ProjectActivityStreamList(SubListAPIView): return qs.filter(project=parent) return qs.filter(Q(project=parent) | Q(credential__in=parent.credential)) +class ProjectNotificationsAnyList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = Project + relationship = 'notification_any' + +class ProjectNotificationsErrorList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = Project + relationship = 'notification_errors' + +class ProjectNotificationsSuccessList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = Project + relationship = 'notification_success' class ProjectUpdatesList(SubListAPIView): @@ -899,6 +949,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): @@ -1725,6 +1781,27 @@ class InventorySourceActivityStreamList(SubListAPIView): # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) +class InventorySourceNotificationsAnyList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = InventorySource + relationship = 'notification_any' + +class InventorySourceNotificationsErrorList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = InventorySource + relationship = 'notification_errors' + +class InventorySourceNotificationsSuccessList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = InventorySource + relationship = 'notification_success' + class InventorySourceHostsList(SubListAPIView): model = Host @@ -1789,6 +1866,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 @@ -1943,6 +2027,27 @@ class JobTemplateActivityStreamList(SubListAPIView): # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) +class JobTemplateNotificationsAnyList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = JobTemplate + relationship = 'notification_any' + +class JobTemplateNotificationsErrorList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = JobTemplate + relationship = 'notification_errors' + +class JobTemplateNotificationsSuccessList(SubListCreateAttachDetachAPIView): + + model = NotificationTemplate + serializer_class = NotificationTemplateSerializer + parent_model = JobTemplate + relationship = 'notification_success' + class JobTemplateCallback(GenericAPIView): model = JobTemplate @@ -2129,7 +2234,7 @@ class SystemJobTemplateDetail(RetrieveAPIView): class SystemJobTemplateLaunch(GenericAPIView): model = SystemJobTemplate - # FIXME: Add serializer class to define fields in OPTIONS request! + serializer_class = EmptySerializer def get(self, request, *args, **kwargs): return Response({}) @@ -2276,6 +2381,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 @@ -2926,12 +3038,51 @@ class NotificationTemplateList(ListCreateAPIView): serializer_class = NotificationTemplateSerializer new_in_300 = True -class NotificationTemplateDetail(RetrieveDestroyAPIView): +class NotificationTemplateDetail(RetrieveUpdateDestroyAPIView): model = NotificationTemplate serializer_class = NotificationTemplateSerializer new_in_300 = True +class NotificationTemplateTest(GenericAPIView): + + view_name = 'Notification Template Test' + model = NotificationTemplate + serializer_class = EmptySerializer + new_in_300 = True + + def post(self, request, *args, **kwargs): + obj = self.get_object() + notification = obj.generate_notification("Tower Notification Test", "Ansible Tower Test Notification") + 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 NotificationTemplateNotificationList(SubListAPIView): + + model = Notification + serializer_class = NotificationSerializer + parent_model = NotificationTemplate + relationship = 'notifications' + parent_key = 'notifier' + +class NotificationList(ListAPIView): + + model = Notification + serializer_class = NotificationSerializer + new_in_300 = True + +class NotificationDetail(RetrieveAPIView): + + model = NotificationTemplate + serializer_class = NotificationSerializer + new_in_300 = True + class ActivityStreamList(SimpleListAPIView): model = ActivityStream diff --git a/awx/main/access.py b/awx/main/access.py index e4ef4653a7..3ffbaf7f85 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1496,6 +1496,19 @@ class NotificationTemplateAccess(BaseAccess): 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 @@ -1696,3 +1709,4 @@ register_access(ActivityStream, ActivityStreamAccess) register_access(CustomInventoryScript, CustomInventoryScriptAccess) register_access(TowerSettings, TowerSettingsAccess) register_access(NotificationTemplate, NotificationTemplateAccess) +register_access(Notification, NotificationAccess) diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 4e6d45f18f..2397b6137b 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -61,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(NotificationTemplate) +activity_stream_registrar.connect(Notification) diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index b695831ada..12a54c7af2 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -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) + notification_template = models.ManyToManyField("NotificationTemplate", blank=True) + notification = models.ManyToManyField("Notification", blank=True) def get_absolute_url(self): return reverse('api:activity_stream_detail', args=(self.pk,)) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 61515d7d18..f3f158855c 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -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 + + notification_errors = models.ManyToManyField( + "NotificationTemplate", + blank=True, + related_name='%(class)s_notifications_for_errors' + ) + + notification_success = models.ManyToManyField( + "NotificationTemplate", + blank=True, + related_name='%(class)s_notifications_for_success' + ) + + notification_any = models.ManyToManyField( + "NotificationTemplate", + blank=True, + related_name='%(class)s_notifications_for_any' + ) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 37b1dafc4b..febf010f20 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -23,6 +23,7 @@ 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 NotificationTemplate from awx.main.utils import ignore_inventory_computed_fields, _inventory_updates __all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'CustomInventoryScript'] @@ -1180,6 +1181,15 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions): return True return False + @property + def notifiers(self): + # Return all notifiers defined on the Project, and on the Organization for each trigger type + base_notifiers = NotificationTemplate.objects.filter(active=True) + error_notifiers = list(base_notifiers.filter(organization_notifications_for_errors__in=self)) + success_notifiers = list(base_notifiers.filter(organization_notifications_for_success__in=self)) + any_notifiers = list(base_notifiers.filter(organization_notifications_for_any__in=self)) + return dict(error=error_notifiers, success=success_notifiers, any=any_notifiers) + def clean_source(self): source = self.source if source and self.group: diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 833d20a9b4..42f7ccf676 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -22,6 +22,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 NotificationTemplate 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 +331,16 @@ 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 = NotificationTemplate.objects.filter(active=True) + error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifications_for_errors__in=[self, self.project])) + success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifications_for_success__in=[self, self.project])) + any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifications_for_any__in=[self, self.project])) + return dict(error=error_notifiers, success=success_notifiers, any=any_notifiers) class Job(UnifiedJob, JobOptions): ''' diff --git a/awx/main/models/notifications.py b/awx/main/models/notifications.py index 81c5b31e7f..a89f460e64 100644 --- a/awx/main/models/notifications.py +++ b/awx/main/models/notifications.py @@ -5,7 +5,9 @@ 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.notifications.email_backend import CustomEmailBackend @@ -17,7 +19,7 @@ from jsonfield import JSONField logger = logging.getLogger('awx.main.models.notifications') -__all__ = ['NotificationTemplate'] +__all__ = ['NotificationTemplate', 'Notification'] class NotificationTemplate(CommonModel): @@ -30,6 +32,14 @@ class NotificationTemplate(CommonModel): class Meta: app_label = 'main' + organization = models.ForeignKey( + 'Organization', + blank=False, + null=True, + on_delete=models.SET_NULL, + related_name='notification_templates', + ) + notification_type = models.CharField( max_length = 32, choices=NOTIFICATION_TYPE_CHOICES, @@ -42,4 +52,83 @@ class NotificationTemplate(CommonModel): @property def notification_class(self): - return CLASS_FOR_NOTIFICATION_TYPE[self.notification_type] + return self.CLASS_FOR_NOTIFICATION_TYPE[self.notification_type] + + @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): + recipients = self.notification_configuration.pop(self.notification_class.recipient_parameter) + sender = self.notification_configuration.pop(self.notification_class.sender_parameter, None) + backend_obj = self.notification_class(**self.notification_configuration) + notification_obj = EmailMessage(subject, 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( + 'NotificationTemplate', + 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=NotificationTemplate.NOTIFICATION_TYPE_CHOICES, + ) + recipients = models.TextField( + blank=True, + default='', + editable=False, + ) + subject = models.TextField( + blank=True, + default='', + editable=False, + ) + body = models.TextField( + blank=True, + default='', + editable=False, + ) + + def get_absolute_url(self): + return reverse('api:notification_detail', args=(self.pk,)) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index c22b907082..58f563735b 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -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 ''' diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 2fa6512ca0..730604d3e4 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -10,6 +10,7 @@ import urlparse # Django from django.conf import settings from django.db import models +from django.db.models import Q from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import smart_str, smart_text from django.core.exceptions import ValidationError @@ -20,6 +21,7 @@ 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 NotificationTemplate from awx.main.models.unified_jobs import * # noqa from awx.main.utils import update_scm_url @@ -309,6 +311,23 @@ class Project(UnifiedJobTemplate, ProjectOptions): return True return False + @property + def notifiers(self): + # Return all notifiers defined 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 back once that is + # available after the rbac pr + base_notifiers = NotificationTemplate.objects.filter(active=True) + # error_notifiers = list(base_notifiers.filter(Q(project_notifications_for_errors__in=self) | + # Q(organization_notifications_for_errors__in=self.organization))) + # success_notifiers = list(base_notifiers.filter(Q(project_notifications_for_success__in=self) | + # Q(organization_notifications_for_success__in=self.organization))) + # any_notifiers = list(base_notifiers.filter(Q(project_notifications_for_any__in=self) | + # Q(organization_notifications_for_any__in=self.organization))) + error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifications_for_errors=self)) + success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifications_for_success=self)) + any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifications_for_any=self)) + return dict(error=error_notifiers, success=success_notifiers, any=any_notifiers) + def get_absolute_url(self): return reverse('api:project_detail', args=(self.pk,)) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index cd519af726..c6ea2b082b 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -30,6 +30,7 @@ from djcelery.models import TaskMeta # AWX from awx.main.models.base import * # noqa from awx.main.models.schedules import Schedule +from awx.main.models.notifications import Notification from awx.main.utils import decrypt_field, emit_websocket_notification, _inventory_updates from awx.main.redact import UriCleaner @@ -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 NotificationTemplate.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, diff --git a/awx/main/notifications/email_backend.py b/awx/main/notifications/email_backend.py index db0a8b3c2f..271f585d5c 100644 --- a/awx/main/notifications/email_backend.py +++ b/awx/main/notifications/email_backend.py @@ -12,5 +12,9 @@ class CustomEmailBackend(EmailBackend): "username": {"label": "Username", "type": "string"}, "password": {"label": "Password", "type": "password"}, "use_tls": {"label": "Use TLS", "type": "bool"}, - "use_ssl": {"label": "Use SSL", "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" diff --git a/awx/main/notifications/slack_backend.py b/awx/main/notifications/slack_backend.py index 84ae60c3cb..950d5c2c6e 100644 --- a/awx/main/notifications/slack_backend.py +++ b/awx/main/notifications/slack_backend.py @@ -10,7 +10,10 @@ logger = logging.getLogger('awx.main.notifications.slack_backend') class SlackBackend(BaseEmailBackend): - init_parameters = {"token": {"label": "Token", "type": "password"}} + 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) @@ -37,8 +40,9 @@ class SlackBackend(BaseEmailBackend): sent_messages = 0 for m in messages: try: - self.connection.rtm_send_message(m.to, m.body) - sent_messages += 1 + for r in m.recipients(): + self.connection.rtm_send_message(r, m.body) + sent_messages += 1 except Exception as e: if not self.fail_silently: raise diff --git a/awx/main/notifications/twilio_backend.py b/awx/main/notifications/twilio_backend.py index d0d2fbfe76..cf2ced368b 100644 --- a/awx/main/notifications/twilio_backend.py +++ b/awx/main/notifications/twilio_backend.py @@ -13,7 +13,10 @@ class TwilioBackend(BaseEmailBackend): init_parameters = {"account_sid": {"label": "Account SID", "type": "string"}, "account_token": {"label": "Account Token", "type": "password"}, - "from_phone": {"label": "Source Phone Number", "type": "string"}} + "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, from_phone, fail_silently=False, **kwargs): super(TwilioBackend, self).__init__(fail_silently=fail_silently) @@ -34,7 +37,7 @@ class TwilioBackend(BaseEmailBackend): try: connection.messages.create( to=m.to, - from_=self.from_phone, + from_=m.from_email, body=m.body) sent_messages += 1 except Exception as e: diff --git a/awx/main/signals.py b/awx/main/signals.py index 8b0c22ec9d..f4d0014905 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -307,6 +307,8 @@ model_serializer_mapping = { Job: JobSerializer, AdHocCommand: AdHocCommandSerializer, TowerSettings: TowerSettingsSerializer, + NotificationTemplate: NotificationTemplateSerializer, + Notification: NotificationSerializer, } def activity_stream_create(sender, instance, created, **kwargs): diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 478bb6275c..aff9a6a585 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -53,7 +53,7 @@ from awx.fact.utils.connection import test_mongo_connection __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', 'RunAdHocCommand', 'handle_work_error', 'handle_work_success', - 'update_inventory_computed_fields'] + 'update_inventory_computed_fields', 'send_notifications'] HIDDEN_PASSWORD = '**********' @@ -65,6 +65,26 @@ 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") + 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 = str(e) + finally: + notification.save() + if job_id is not None: + j = UnifiedJob.objects.get(id=job_id) + j.notifications.add(notification) + @task() def bulk_inventory_element_delete(inventory, hosts=[], groups=[]): from awx.main.signals import disable_activity_stream @@ -162,12 +182,41 @@ def mongodb_control(cmd): @task(bind=True) def handle_work_success(self, result, task_actual): - # TODO: Perform Notification tasks - pass + 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_subject = "{} #{} '{}' succeeded on Ansible Tower".format(friendly_name, + task_actual['id'], + instance_name) + notification_body = "{} #{} '{}' succeeded on Ansible Tower\nTo view the output: {}".format(friendly_name, + task_actual['id'], + instance_name, + instance.get_absolute_url()) + send_notifications.delay([n.generate_notification(notification_subject, notification_body) + for n in notifiers.get('success', []) + notifiers.get('any', [])], + job_id=task_actual['id']) @task(bind=True) def handle_work_error(self, task_id, subtasks=None): - # TODO: Perform Notification tasks print('Executing error task id %s, subtasks: %s' % (str(self.request.id), str(subtasks))) first_task = None @@ -180,15 +229,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 @@ -197,6 +254,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 @@ -204,6 +262,17 @@ 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_subject = "{} #{} '{}' failed on Ansible Tower".format(first_task_friendly_name, + first_task_id, + first_task_name) + notification_body = "{} #{} '{}' failed on Ansible Tower\nTo view the output: {}".format(first_task_friendly_name, + first_task_id, + first_task_name, + first_task.get_absolute_url()) + send_notifications.delay([n.generate_notification(notification_subject, notification_body).id + for n in notifiers.get('error', []) + notifiers.get('any', [])], + job_id=first_task_id) + @task() def update_inventory_computed_fields(inventory_id, should_update_hosts=True):