mirror of
https://github.com/getredash/redash.git
synced 2025-12-19 17:37:19 -05:00
Feature: alerts for query results.
This is basic implementation for alerts feature, where you can define a simple rule on the last query result to send an alert. As part of the implementation added Flask-Mail to the project, to send emails. Should be useful to make re:dash more "self aware" (notify users about potential issues, when queries done executing and more).
This commit is contained in:
13
manage.py
13
manage.py
@@ -43,12 +43,15 @@ def make_shell_context():
|
||||
@manager.command
|
||||
def check_settings():
|
||||
"""Show the settings as re:dash sees them (useful for debugging)."""
|
||||
from types import ModuleType
|
||||
for name, item in settings.all_settings().iteritems():
|
||||
print "{} = {}".format(name, item)
|
||||
|
||||
for name in dir(settings):
|
||||
item = getattr(settings, name)
|
||||
if not callable(item) and not name.startswith("__") and not isinstance(item, ModuleType):
|
||||
print "{} = {}".format(name, item)
|
||||
@manager.command
|
||||
def send_test_mail():
|
||||
from redash import mail
|
||||
from flask_mail import Message
|
||||
|
||||
mail.send(Message(subject="Test Message from re:dash", recipients=[settings.MAIL_DEFAULT_SENDER], body="Test message."))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
8
migrations/0010_create_alerts.py
Normal file
8
migrations/0010_create_alerts.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from redash.models import db, Alert, AlertSubscription
|
||||
|
||||
if __name__ == '__main__':
|
||||
with db.database.transaction():
|
||||
Alert.create_table()
|
||||
AlertSubscription.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
@@ -164,6 +164,7 @@
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<script>
|
||||
|
||||
@@ -80,9 +80,14 @@ angular.module('redash', [
|
||||
templateUrl: '/views/admin_status.html',
|
||||
controller: 'AdminStatusCtrl'
|
||||
});
|
||||
$routeProvider.when('/admin/workers', {
|
||||
templateUrl: '/views/admin_workers.html',
|
||||
controller: 'AdminWorkersCtrl'
|
||||
|
||||
$routeProvider.when('/alerts', {
|
||||
templateUrl: '/views/alerts/list.html',
|
||||
controller: 'AlertsCtrl'
|
||||
});
|
||||
$routeProvider.when('/alerts/:alertId', {
|
||||
templateUrl: '/views/alerts/edit.html',
|
||||
controller: 'AlertCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/', {
|
||||
|
||||
171
rd_ui/app/scripts/controllers/alerts.js
Normal file
171
rd_ui/app/scripts/controllers/alerts.js
Normal file
@@ -0,0 +1,171 @@
|
||||
(function() {
|
||||
|
||||
var AlertsCtrl = function($scope, Events, Alert) {
|
||||
Events.record(currentUser, "view", "page", "alerts");
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alerts = []
|
||||
Alert.query(function(alerts) {
|
||||
var stateClass = {
|
||||
'ok': 'label label-success',
|
||||
'triggered': 'label label-danger',
|
||||
'unknown': 'label label-warning'
|
||||
};
|
||||
_.each(alerts, function(alert) {
|
||||
alert.class = stateClass[alert.state];
|
||||
})
|
||||
$scope.alerts = alerts;
|
||||
|
||||
});
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="/alerts/{{dataRow.id}}">{{dataRow.name}}</a> (<a href="/queries/{{dataRow.query.id}}">query</a>)'
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'State',
|
||||
'cellTemplate': '<span ng-class="dataRow.class">{{dataRow.state | uppercase}}</span> since <span am-time-ago="dataRow.updated_at"></span>'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
'cellTemplate': '<span am-time-ago="dataRow.created_at"></span>'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
var AlertCtrl = function($scope, $routeParams, growl, Query, Events, Alert) {
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alertId = $routeParams.alertId;
|
||||
if ($scope.alertId === "new") {
|
||||
Events.record(currentUser, 'view', 'page', 'alerts/new');
|
||||
} else {
|
||||
Events.record(currentUser, 'view', 'alert', $scope.alertId);
|
||||
}
|
||||
|
||||
$scope.onQuerySelected = function(item) {
|
||||
$scope.selectedQuery = item;
|
||||
item.getQueryResultPromise().then(function(result) {
|
||||
$scope.queryResult = result;
|
||||
$scope.alert.options.column = result.getColumnNames()[0];
|
||||
});
|
||||
};
|
||||
|
||||
if ($scope.alertId === "new") {
|
||||
$scope.alert = new Alert({options: {}});
|
||||
} else {
|
||||
$scope.alert = Alert.get({id: $scope.alertId}, function(alert) {
|
||||
$scope.onQuerySelected(new Query($scope.alert.query));
|
||||
});
|
||||
}
|
||||
|
||||
$scope.ops = ['greater than', 'less than', 'equals'];
|
||||
$scope.selectedQuery = null;
|
||||
|
||||
$scope.getDefaultName = function() {
|
||||
if (!$scope.alert.query) {
|
||||
return undefined;
|
||||
}
|
||||
return _.template("<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>", $scope.alert);
|
||||
};
|
||||
|
||||
$scope.searchQueries = function (term) {
|
||||
if (!term || term.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.search({q: term}, function(results) {
|
||||
$scope.queries = results;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
if ($scope.alert.name === undefined || $scope.alert.name === '') {
|
||||
$scope.alert.name = $scope.getDefaultName();
|
||||
}
|
||||
|
||||
$scope.alert.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving alert.");
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/alerts/subscribers.html',
|
||||
scope: {
|
||||
'alertId': '='
|
||||
},
|
||||
controller: function ($scope) {
|
||||
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>',
|
||||
controller: function ($scope) {
|
||||
var updateClass = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.class = "fa fa-eye-slash";
|
||||
} else {
|
||||
$scope.class = "fa fa-eye";
|
||||
}
|
||||
}
|
||||
|
||||
$scope.subscribers.$promise.then(function() {
|
||||
$scope.subscription = _.find($scope.subscribers, function(subscription) {
|
||||
return (subscription.user.email == currentUser.email);
|
||||
});
|
||||
|
||||
updateClass();
|
||||
});
|
||||
|
||||
$scope.toggleSubscription = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.subscription.$delete(function() {
|
||||
$scope.subscribers = _.without($scope.subscribers, $scope.subscription);
|
||||
$scope.subscription = undefined;
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
} else {
|
||||
$scope.subscription = new AlertSubscription({alert_id: $scope.alertId});
|
||||
$scope.subscription.$save(function() {
|
||||
$scope.subscribers.push($scope.subscription);
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Unsubscription failed.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
|
||||
|
||||
})();
|
||||
@@ -505,7 +505,32 @@
|
||||
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, actions);
|
||||
|
||||
return DataSourceResource;
|
||||
}
|
||||
};
|
||||
|
||||
var AlertSubscription = function ($resource) {
|
||||
var resource = $resource('/api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Alert = function ($resource, $http) {
|
||||
var actions = {
|
||||
save: {
|
||||
method: 'POST',
|
||||
transformRequest: [function(data) {
|
||||
var newData = _.extend({}, data);
|
||||
if (newData.query_id === undefined) {
|
||||
newData.query_id = newData.query.id;
|
||||
delete newData.query;
|
||||
}
|
||||
|
||||
return newData;
|
||||
}].concat($http.defaults.transformRequest)
|
||||
}
|
||||
};
|
||||
var resource = $resource('/api/alerts/:id', {id: '@id'}, actions);
|
||||
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Widget = function ($resource, Query) {
|
||||
var WidgetResource = $resource('/api/widgets/:id', {id: '@id'});
|
||||
@@ -532,5 +557,7 @@
|
||||
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget]);
|
||||
})();
|
||||
|
||||
58
rd_ui/app/views/alerts/edit.html
Normal file
58
rd_ui/app/views/alerts/edit.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/alerts">Alerts</a></li>
|
||||
<li class="active">{{alert.name || getDefaultName() || "New"}}</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<form name="alertForm" ng-submit="saveChanges()" class="form">
|
||||
<div class="form-group">
|
||||
<label>Query</label>
|
||||
<ui-select ng-model="alert.query" theme="bootstrap" reset-search-input="false" on-select="onQuerySelected($item)">
|
||||
<ui-select-match placeholder="Search a query by name">{{$select.selected.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="q in queries"
|
||||
refresh="searchQueries($select.search)"
|
||||
refresh-delay="0">
|
||||
<div ng-bind-html="q.name | highlight: $select.search | trustAsHtml"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-show="selectedQuery">
|
||||
<label>Name</label>
|
||||
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name">
|
||||
</div>
|
||||
|
||||
<div ng-show="queryResult" class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Value column</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="alert.options.column" class="form-control"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Value</label>
|
||||
<div class="col-md-4">
|
||||
<p class="form-control-static">{{queryResult.getData()[0][alert.options.column]}}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Op</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Reference</label>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" ng-model="alert.options.value" placeholder="reference value" required/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" ng-disabled="!alertForm.$valid">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-4" ng-if="alert.id">
|
||||
<alert-subscribers alert-id="alert.id"></alert-subscribers>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
rd_ui/app/views/alerts/list.html
Normal file
16
rd_ui/app/views/alerts/list.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div class="container">
|
||||
<ol class="breadcrumb">
|
||||
<li class="active">Alerts</li>
|
||||
</ol>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p>
|
||||
<a href="/alerts/new" class="btn btn-default"><i class="fa fa-plus"></i> New Alert</a>
|
||||
</p>
|
||||
|
||||
<smart-table rows="alerts" columns="gridColumns"
|
||||
config="gridConfig"
|
||||
class="table table-condensed table-hover"></smart-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
4
rd_ui/app/views/alerts/subscribers.html
Normal file
4
rd_ui/app/views/alerts/subscribers.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div>
|
||||
<strong>Subscribers</strong> <subscribe-button alert-id="alertId" subscribers="subscribers"></subscribe-button><br/>
|
||||
<img ng-src="{{s.user.gravatar_url}}" class="img-circle" alt="{{s.user.name}}" ng-repeat="s in subscribers"/>
|
||||
</div>
|
||||
@@ -25,12 +25,12 @@
|
||||
"marked": "~0.3.2",
|
||||
"bucky": "~0.2.6",
|
||||
"pace": "~0.5.1",
|
||||
"angular-ui-select": "0.8.2",
|
||||
"angular-ui-select": "~0.12.0",
|
||||
"font-awesome": "~4.2.0",
|
||||
"mustache": "~1.0.0",
|
||||
"canvg": "gabelerner/canvg",
|
||||
"angular-ui-bootstrap-bower": "~0.12.1",
|
||||
"leaflet":"~0.7.3"
|
||||
"leaflet": "~0.7.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"angular-mocks": "1.2.18",
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import urlparse
|
||||
import redis
|
||||
from statsd import StatsClient
|
||||
from flask_mail import Mail
|
||||
|
||||
from redash import settings
|
||||
from redash.query_runner import import_query_runners
|
||||
@@ -32,6 +33,8 @@ def create_redis_connection():
|
||||
|
||||
setup_logging()
|
||||
redis_connection = create_redis_connection()
|
||||
mail = Mail()
|
||||
mail.init_mail(settings.all_settings())
|
||||
statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
|
||||
|
||||
import_query_runners(settings.QUERY_RUNNERS)
|
||||
|
||||
@@ -53,7 +53,8 @@ class PasswordHashField(fields.PasswordField):
|
||||
class PgModelConverter(CustomModelConverter):
|
||||
def __init__(self, view, additional=None):
|
||||
additional = {ArrayField: self.handle_array_field,
|
||||
DateTimeTZField: self.handle_datetime_tz_field}
|
||||
DateTimeTZField: self.handle_datetime_tz_field,
|
||||
}
|
||||
super(PgModelConverter, self).__init__(view, additional)
|
||||
self.view = view
|
||||
|
||||
@@ -104,13 +105,8 @@ class DataSourceModelView(BaseModelView):
|
||||
def init_admin(app):
|
||||
admin = Admin(app, name='re:dash admin')
|
||||
|
||||
views = {
|
||||
models.User: UserModelView(models.User),
|
||||
models.DataSource: DataSourceModelView(models.DataSource)
|
||||
}
|
||||
admin.add_view(UserModelView(models.User))
|
||||
admin.add_view(DataSourceModelView(models.DataSource))
|
||||
|
||||
for m in models.all_models:
|
||||
if m in views:
|
||||
admin.add_view(views[m])
|
||||
else:
|
||||
admin.add_view(BaseModelView(m))
|
||||
for m in (models.QueryResult, models.Query, models.Dashboard, models.Visualization, models.Widget, models.ActivityLog, models.Group, models.Event):
|
||||
admin.add_view(BaseModelView(m))
|
||||
|
||||
@@ -13,8 +13,9 @@ import logging
|
||||
|
||||
from flask import render_template, send_from_directory, make_response, request, jsonify, redirect, \
|
||||
session, url_for, current_app, flash
|
||||
from flask.ext.restful import Resource, abort
|
||||
from flask.ext.restful import Resource, abort, reqparse
|
||||
from flask_login import current_user, login_user, logout_user, login_required
|
||||
from funcy import project
|
||||
import sqlparse
|
||||
|
||||
from redash import statsd_client, models, settings, utils
|
||||
@@ -33,6 +34,8 @@ def ping():
|
||||
|
||||
@app.route('/admin/<anything>')
|
||||
@app.route('/dashboard/<anything>')
|
||||
@app.route('/alerts')
|
||||
@app.route('/alerts/<pk>')
|
||||
@app.route('/queries')
|
||||
@app.route('/queries/<query_id>')
|
||||
@app.route('/queries/<query_id>/<anything>')
|
||||
@@ -575,6 +578,105 @@ class JobAPI(BaseResource):
|
||||
|
||||
api.add_resource(JobAPI, '/api/jobs/<job_id>', endpoint='job')
|
||||
|
||||
|
||||
class AlertAPI(BaseResource):
|
||||
def get(self, alert_id):
|
||||
alert = models.Alert.get_by_id(alert_id)
|
||||
return alert.to_dict()
|
||||
|
||||
def post(self, alert_id):
|
||||
req = request.get_json(True)
|
||||
params = project(req, ('options', 'name', 'query_id'))
|
||||
alert = models.Alert.get_by_id(alert_id)
|
||||
if 'query_id' in params:
|
||||
params['query'] = params.pop('query_id')
|
||||
|
||||
alert.update_instance(**params)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'edit',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
|
||||
class AlertListAPI(BaseResource):
|
||||
def post(self):
|
||||
req = request.get_json(True)
|
||||
required_fields = ('options', 'name', 'query_id')
|
||||
for f in required_fields:
|
||||
if f not in req:
|
||||
abort(400)
|
||||
|
||||
alert = models.Alert.create(
|
||||
name=req['name'],
|
||||
query=req['query_id'],
|
||||
user=self.current_user,
|
||||
options=req['options']
|
||||
)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'create',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
# TODO: should be in model?
|
||||
models.AlertSubscription.create(alert=alert, user=self.current_user)
|
||||
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert.id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
return alert.to_dict()
|
||||
|
||||
def get(self):
|
||||
return [alert.to_dict() for alert in models.Alert.all()]
|
||||
|
||||
|
||||
class AlertSubscriptionListResource(BaseResource):
|
||||
def post(self, alert_id):
|
||||
subscription = models.AlertSubscription.create(alert=alert_id, user=self.current_user)
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'subscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
return subscription.to_dict()
|
||||
|
||||
def get(self, alert_id):
|
||||
subscriptions = models.AlertSubscription.all(alert_id)
|
||||
return [s.to_dict() for s in subscriptions]
|
||||
|
||||
|
||||
class AlertSubscriptionResource(BaseResource):
|
||||
def delete(self, alert_id, subscriber_id):
|
||||
models.AlertSubscription.unsubscribe(alert_id, subscriber_id)
|
||||
record_event.delay({
|
||||
'user_id': self.current_user.id,
|
||||
'action': 'unsubscribe',
|
||||
'timestamp': int(time.time()),
|
||||
'object_id': alert_id,
|
||||
'object_type': 'alert'
|
||||
})
|
||||
|
||||
api.add_resource(AlertAPI, '/api/alerts/<alert_id>', endpoint='alert')
|
||||
api.add_resource(AlertSubscriptionListResource, '/api/alerts/<alert_id>/subscriptions', endpoint='alert_subscriptions')
|
||||
api.add_resource(AlertSubscriptionResource, '/api/alerts/<alert_id>/subscriptions/<subscriber_id>', endpoint='alert_subscription')
|
||||
api.add_resource(AlertListAPI, '/api/alerts', endpoint='alerts')
|
||||
|
||||
@app.route('/<path:filename>')
|
||||
def send_static(filename):
|
||||
if current_app.debug:
|
||||
@@ -583,7 +685,3 @@ def send_static(filename):
|
||||
cache_timeout = None
|
||||
|
||||
return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
||||
|
||||
117
redash/models.py
117
redash/models.py
@@ -77,6 +77,17 @@ class BaseModel(peewee.Model):
|
||||
super(BaseModel, self).save(*args, **kwargs)
|
||||
self.post_save(created)
|
||||
|
||||
def update_instance(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
# setattr(model_instance, field_name, field_obj.python_value(value))
|
||||
setattr(self, k, v)
|
||||
|
||||
dirty_fields = self.dirty_fields
|
||||
if hasattr(self, 'updated_at'):
|
||||
dirty_fields = dirty_fields + [self.__class__.updated_at]
|
||||
|
||||
self.save(only=dirty_fields)
|
||||
|
||||
|
||||
class ModelTimestampsMixin(BaseModel):
|
||||
updated_at = DateTimeTZField(default=datetime.datetime.now)
|
||||
@@ -163,6 +174,7 @@ class User(ModelTimestampsMixin, BaseModel, UserMixin, PermissionsCheckMixin):
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'email': self.email,
|
||||
'gravatar_url': self.gravatar_url,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
@@ -177,6 +189,11 @@ class User(ModelTimestampsMixin, BaseModel, UserMixin, PermissionsCheckMixin):
|
||||
if not self.api_key:
|
||||
self.api_key = generate_token(40)
|
||||
|
||||
@property
|
||||
def gravatar_url(self):
|
||||
email_md5 = hashlib.md5(self.email.lower()).hexdigest()
|
||||
return "https://www.gravatar.com/avatar/%s?s=40" % email_md5
|
||||
|
||||
@property
|
||||
def permissions(self):
|
||||
# TODO: this should be cached.
|
||||
@@ -280,6 +297,13 @@ class DataSource(BaseModel):
|
||||
def all(cls):
|
||||
return cls.select().order_by(cls.id.asc())
|
||||
|
||||
class JSONField(peewee.TextField):
|
||||
def db_value(self, value):
|
||||
return json.dumps(value)
|
||||
|
||||
def python_value(self, value):
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
class QueryResult(BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
@@ -338,13 +362,17 @@ class QueryResult(BaseModel):
|
||||
|
||||
logging.info("Inserted query (%s) data; id=%s", query_hash, query_result.id)
|
||||
|
||||
updated_count = Query.update(latest_query_data=query_result).\
|
||||
where(Query.query_hash==query_hash, Query.data_source==data_source_id).\
|
||||
execute()
|
||||
sql = "UPDATE queries SET latest_query_data_id = %s WHERE query_hash = %s AND data_source_id = %s RETURNING id"
|
||||
query_ids = [row[0] for row in db.database.execute_sql(sql, params=(query_result.id, query_hash, data_source_id))]
|
||||
|
||||
logging.info("Updated %s queries with result (%s).", updated_count, query_hash)
|
||||
# TODO: when peewee with update & returning support is released, we can get back to using this code:
|
||||
# updated_count = Query.update(latest_query_data=query_result).\
|
||||
# where(Query.query_hash==query_hash, Query.data_source==data_source_id).\
|
||||
# execute()
|
||||
|
||||
return query_result
|
||||
logging.info("Updated %s queries with result (%s).", len(query_ids), query_hash)
|
||||
|
||||
return query_result, query_ids
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%d | %s | %s" % (self.id, self.query_hash, self.retrieved_at)
|
||||
@@ -527,6 +555,83 @@ class Query(ModelTimestampsMixin, BaseModel):
|
||||
return unicode(self.id)
|
||||
|
||||
|
||||
class Alert(ModelTimestampsMixin, BaseModel):
|
||||
UNKNOWN_STATE = 'unknown'
|
||||
OK_STATE = 'ok'
|
||||
TRIGGERED_STATE = 'triggered'
|
||||
|
||||
id = peewee.PrimaryKeyField()
|
||||
name = peewee.CharField()
|
||||
query = peewee.ForeignKeyField(Query, related_name='alerts')
|
||||
user = peewee.ForeignKeyField(User, related_name='alerts')
|
||||
options = JSONField()
|
||||
state = peewee.CharField(default=UNKNOWN_STATE)
|
||||
last_triggered_at = DateTimeTZField(null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'alerts'
|
||||
|
||||
@classmethod
|
||||
def all(cls):
|
||||
return cls.select(Alert, User, Query).join(Query).switch(Alert).join(User)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'query': self.query.to_dict(),
|
||||
'user': self.user.to_dict(),
|
||||
'options': self.options,
|
||||
'state': self.state,
|
||||
'last_triggered_at': self.last_triggered_at,
|
||||
'updated_at': self.updated_at,
|
||||
'created_at': self.created_at
|
||||
}
|
||||
|
||||
def evaluate(self):
|
||||
data = json.loads(self.query.latest_query_data.data)
|
||||
# todo: safe guard for empty
|
||||
value = data['rows'][0][self.options['column']]
|
||||
op = self.options['op']
|
||||
|
||||
if op == 'greater than' and value > self.options['value']:
|
||||
new_state = self.TRIGGERED_STATE
|
||||
elif op == 'less than' and value < self.options['value']:
|
||||
new_state = self.TRIGGERED_STATE
|
||||
elif op == 'equals' and value == self.options['value']:
|
||||
new_state = self.TRIGGERED_STATE
|
||||
else:
|
||||
new_state = self.OK_STATE
|
||||
|
||||
return new_state
|
||||
|
||||
def subscribers(self):
|
||||
return User.select().join(AlertSubscription).where(AlertSubscription.alert==self)
|
||||
|
||||
|
||||
class AlertSubscription(ModelTimestampsMixin, BaseModel):
|
||||
user = peewee.ForeignKeyField(User)
|
||||
alert = peewee.ForeignKeyField(Alert)
|
||||
|
||||
class Meta:
|
||||
db_table = 'alert_subscriptions'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'user': self.user.to_dict(),
|
||||
'alert_id': self._data['alert']
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def all(cls, alert_id):
|
||||
return AlertSubscription.select(AlertSubscription, User).join(User).where(AlertSubscription.alert==alert_id)
|
||||
|
||||
@classmethod
|
||||
def unsubscribe(cls, alert_id, user_id):
|
||||
query = AlertSubscription.delete().where(AlertSubscription.alert==alert_id).where(AlertSubscription.user==user_id)
|
||||
return query.execute()
|
||||
|
||||
|
||||
class Dashboard(ModelTimestampsMixin, BaseModel):
|
||||
id = peewee.PrimaryKeyField()
|
||||
slug = peewee.CharField(max_length=140, index=True)
|
||||
@@ -716,7 +821,7 @@ class Event(BaseModel):
|
||||
return event
|
||||
|
||||
|
||||
all_models = (DataSource, User, QueryResult, Query, Dashboard, Visualization, Widget, ActivityLog, Group, Event)
|
||||
all_models = (DataSource, User, QueryResult, Query, Alert, Dashboard, Visualization, Widget, ActivityLog, Group, Event)
|
||||
|
||||
|
||||
def init_db():
|
||||
|
||||
@@ -40,6 +40,17 @@ def parse_boolean(str):
|
||||
return json.loads(str.lower())
|
||||
|
||||
|
||||
def all_settings():
|
||||
from types import ModuleType
|
||||
|
||||
settings = {}
|
||||
for name, item in globals().iteritems():
|
||||
if not callable(item) and not name.startswith("__") and not isinstance(item, ModuleType):
|
||||
settings[name] = item
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
NAME = os.environ.get('REDASH_NAME', 're:dash')
|
||||
|
||||
REDIS_URL = os.environ.get('REDASH_REDIS_URL', "redis://localhost:6379/0")
|
||||
@@ -81,6 +92,19 @@ LOG_LEVEL = os.environ.get("REDASH_LOG_LEVEL", "INFO")
|
||||
CLIENT_SIDE_METRICS = parse_boolean(os.environ.get("REDASH_CLIENT_SIDE_METRICS", "false"))
|
||||
ANALYTICS = os.environ.get("REDASH_ANALYTICS", "")
|
||||
|
||||
# Mail settings:
|
||||
MAIL_SERVER = os.environ.get('REDASH_MAIL_SERVER', 'localhost')
|
||||
MAIL_PORT = int(os.environ.get('REDASH_MAIL_PORT', 25))
|
||||
MAIL_USE_TLS = parse_boolean(os.environ.get('REDASH_MAIL_USE_TLS', 'false'))
|
||||
MAIL_USE_SSL = parse_boolean(os.environ.get('REDASH_MAIL_USE_SSL', 'false'))
|
||||
MAIL_USERNAME = os.environ.get('REDASH_MAIL_USERNAME', None)
|
||||
MAIL_PASSWORD = os.environ.get('REDASH_MAIL_PASSWORD', None)
|
||||
MAIL_DEFAULT_SENDER = os.environ.get('REDASH_MAIL_DEFAULT_SENDER', None)
|
||||
MAIL_MAX_EMAILS = os.environ.get('REDASH_MAIL_MAX_EMAILS', None)
|
||||
MAIL_ASCII_ATTACHMENTS = parse_boolean(os.environ.get('REDASH_MAIL_ASCII_ATTACHMENTS', 'false'))
|
||||
|
||||
HOST = ""
|
||||
|
||||
# CORS settings for the Query Result API (and possbily future external APIs).
|
||||
# In most cases all you need to do is set REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN
|
||||
# to the calling domain (or domains in a comma separated list).
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import time
|
||||
import logging
|
||||
from flask.ext.mail import Message
|
||||
import redis
|
||||
from celery import Task
|
||||
from celery.result import AsyncResult
|
||||
from celery.utils.log import get_task_logger
|
||||
from redash import redis_connection, models, statsd_client, settings, utils
|
||||
from redash import redis_connection, models, statsd_client, settings, utils, mail
|
||||
from redash.utils import gen_query_hash
|
||||
from redash.worker import celery
|
||||
from redash.query_runner import get_query_runner
|
||||
@@ -222,7 +223,7 @@ def cleanup_query_results():
|
||||
@celery.task(base=BaseTask)
|
||||
def refresh_schemas():
|
||||
"""
|
||||
Refershs the datasources schema.
|
||||
Refreshs the datasources schema.
|
||||
"""
|
||||
|
||||
for ds in models.DataSource.all():
|
||||
@@ -230,6 +231,39 @@ def refresh_schemas():
|
||||
ds.get_schema(refresh=True)
|
||||
|
||||
|
||||
@celery.task(bind=True, base=BaseTask)
|
||||
def check_alerts_for_query(self, query_id):
|
||||
from redash.wsgi import app
|
||||
|
||||
logger.debug("Checking query %d for alerts", query_id)
|
||||
query = models.Query.get_by_id(query_id)
|
||||
for alert in query.alerts:
|
||||
alert.query = query
|
||||
new_state = alert.evaluate()
|
||||
if new_state != alert.state:
|
||||
logger.info("Alert %d new state: %s", alert.id, new_state)
|
||||
old_state = alert.state
|
||||
alert.update_instance(state=new_state)
|
||||
|
||||
if old_state == models.Alert.UNKNOWN_STATE and new_state == models.Alert.OK_STATE:
|
||||
logger.debug("Skipping notification (previous state was unknown and now it's ok).")
|
||||
continue
|
||||
|
||||
# message = Message
|
||||
recipients = [s.email for s in alert.subscribers()]
|
||||
logger.debug("Notifying: %s", recipients)
|
||||
html = """
|
||||
Check <a href="{host}/alerts/{alert_id}">alert</a> / check <a href="{host}/queries/{query_id}">query</a>.
|
||||
""".format(host=settings.HOST, alert_id=alert.id, query_id=query.id)
|
||||
|
||||
with app.app_context():
|
||||
message = Message(recipients=recipients,
|
||||
subject="[{1}] {0}".format(alert.name, new_state.upper()),
|
||||
html=html)
|
||||
|
||||
mail.send(message)
|
||||
|
||||
|
||||
@celery.task(bind=True, base=BaseTask, track_started=True)
|
||||
def execute_query(self, query, data_source_id, metadata):
|
||||
start_time = time.time()
|
||||
@@ -271,7 +305,9 @@ def execute_query(self, query, data_source_id, metadata):
|
||||
redis_connection.delete(QueryTask._job_lock_id(query_hash, data_source.id))
|
||||
|
||||
if not error:
|
||||
query_result = models.QueryResult.store_result(data_source.id, query_hash, query, data, run_time, utils.utcnow())
|
||||
query_result, updated_query_ids = models.QueryResult.store_result(data_source.id, query_hash, query, data, run_time, utils.utcnow())
|
||||
for query_id in updated_query_ids:
|
||||
check_alerts_for_query.delay(query_id)
|
||||
else:
|
||||
raise Exception(error)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from flask import Flask, make_response
|
||||
from werkzeug.wrappers import Response
|
||||
from flask.ext.restful import Api
|
||||
|
||||
from redash import settings, utils
|
||||
from redash import settings, utils, mail
|
||||
from redash.models import db
|
||||
from redash.admin import init_admin
|
||||
|
||||
@@ -22,7 +22,9 @@ init_admin(app)
|
||||
# configure our database
|
||||
settings.DATABASE_CONFIG.update({'threadlocals': True})
|
||||
app.config['DATABASE'] = settings.DATABASE_CONFIG
|
||||
app.config.update(settings.all_settings())
|
||||
db.init_app(app)
|
||||
mail.init_app(app)
|
||||
|
||||
from redash.authentication import setup_authentication
|
||||
setup_authentication(app)
|
||||
|
||||
@@ -3,6 +3,7 @@ Flask-Admin==1.1.0
|
||||
Flask-RESTful==0.2.10
|
||||
Flask-Login==0.2.11
|
||||
Flask-OAuth==0.12
|
||||
flask-mail==0.9.1
|
||||
passlib==1.6.2
|
||||
Jinja2==2.7.2
|
||||
MarkupSafe==0.18
|
||||
@@ -29,4 +30,5 @@ click==3.3
|
||||
RestrictedPython==3.6.0
|
||||
wtf-peewee==0.2.3
|
||||
pysaml2==2.4.0
|
||||
pycrypto==2.6.1
|
||||
pycrypto==2.6.1
|
||||
funcy==1.5
|
||||
|
||||
@@ -164,7 +164,7 @@ class QueryArchiveTest(BaseTestCase):
|
||||
def test_archived_query_doesnt_return_in_all(self):
|
||||
query = query_factory.create(schedule="1")
|
||||
yesterday = datetime.datetime.now() - datetime.timedelta(days=1)
|
||||
query_result = models.QueryResult.store_result(query.data_source.id, query.query_hash, query.query, "1",
|
||||
query_result, _ = models.QueryResult.store_result(query.data_source.id, query.query_hash, query.query, "1",
|
||||
123, yesterday)
|
||||
|
||||
query.latest_query_data = query_result
|
||||
@@ -329,7 +329,7 @@ class TestQueryResultStoreResult(BaseTestCase):
|
||||
self.data = "data"
|
||||
|
||||
def test_stores_the_result(self):
|
||||
query_result = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query,
|
||||
query_result, _ = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query,
|
||||
self.data, self.runtime, self.utcnow)
|
||||
|
||||
self.assertEqual(query_result.data, self.data)
|
||||
@@ -344,7 +344,7 @@ class TestQueryResultStoreResult(BaseTestCase):
|
||||
query2 = query_factory.create(query=self.query, data_source=self.data_source)
|
||||
query3 = query_factory.create(query=self.query, data_source=self.data_source)
|
||||
|
||||
query_result = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query, self.data,
|
||||
query_result, _ = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query, self.data,
|
||||
self.runtime, self.utcnow)
|
||||
|
||||
self.assertEqual(models.Query.get_by_id(query1.id)._data['latest_query_data'], query_result.id)
|
||||
@@ -356,7 +356,7 @@ class TestQueryResultStoreResult(BaseTestCase):
|
||||
query2 = query_factory.create(query=self.query, data_source=self.data_source)
|
||||
query3 = query_factory.create(query=self.query + "123", data_source=self.data_source)
|
||||
|
||||
query_result = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query, self.data,
|
||||
query_result, _ = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query, self.data,
|
||||
self.runtime, self.utcnow)
|
||||
|
||||
self.assertEqual(models.Query.get_by_id(query1.id)._data['latest_query_data'], query_result.id)
|
||||
@@ -368,7 +368,7 @@ class TestQueryResultStoreResult(BaseTestCase):
|
||||
query2 = query_factory.create(query=self.query, data_source=self.data_source)
|
||||
query3 = query_factory.create(query=self.query, data_source=data_source_factory.create())
|
||||
|
||||
query_result = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query, self.data,
|
||||
query_result, _ = models.QueryResult.store_result(self.data_source.id, self.query_hash, self.query, self.data,
|
||||
self.runtime, self.utcnow)
|
||||
|
||||
self.assertEqual(models.Query.get_by_id(query1.id)._data['latest_query_data'], query_result.id)
|
||||
|
||||
Reference in New Issue
Block a user