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:
Arik Fraimovich
2015-07-09 08:38:42 +03:00
parent fdff799d23
commit 3d859ec5f3
19 changed files with 601 additions and 42 deletions

View File

@@ -43,12 +43,15 @@ def make_shell_context():
@manager.command @manager.command
def check_settings(): def check_settings():
"""Show the settings as re:dash sees them (useful for debugging).""" """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): @manager.command
item = getattr(settings, name) def send_test_mail():
if not callable(item) and not name.startswith("__") and not isinstance(item, ModuleType): from redash import mail
print "{} = {}".format(name, item) 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__': if __name__ == '__main__':

View 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)

View File

@@ -164,6 +164,7 @@
<script src="/scripts/directives/query_directives.js"></script> <script src="/scripts/directives/query_directives.js"></script>
<script src="/scripts/directives/dashboard_directives.js"></script> <script src="/scripts/directives/dashboard_directives.js"></script>
<script src="/scripts/filters.js"></script> <script src="/scripts/filters.js"></script>
<script src="/scripts/controllers/alerts.js"></script>
<!-- endbuild --> <!-- endbuild -->
<script> <script>

View File

@@ -80,9 +80,14 @@ angular.module('redash', [
templateUrl: '/views/admin_status.html', templateUrl: '/views/admin_status.html',
controller: 'AdminStatusCtrl' controller: 'AdminStatusCtrl'
}); });
$routeProvider.when('/admin/workers', {
templateUrl: '/views/admin_workers.html', $routeProvider.when('/alerts', {
controller: 'AdminWorkersCtrl' templateUrl: '/views/alerts/list.html',
controller: 'AlertsCtrl'
});
$routeProvider.when('/alerts/:alertId', {
templateUrl: '/views/alerts/edit.html',
controller: 'AlertCtrl'
}); });
$routeProvider.when('/', { $routeProvider.when('/', {

View 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])
})();

View File

@@ -505,7 +505,32 @@
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, actions); var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, actions);
return DataSourceResource; 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 Widget = function ($resource, Query) {
var WidgetResource = $resource('/api/widgets/:id', {id: '@id'}); var WidgetResource = $resource('/api/widgets/:id', {id: '@id'});
@@ -532,5 +557,7 @@
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult]) .factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query]) .factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
.factory('DataSource', ['$resource', DataSource]) .factory('DataSource', ['$resource', DataSource])
.factory('Alert', ['$resource', '$http', Alert])
.factory('AlertSubscription', ['$resource', AlertSubscription])
.factory('Widget', ['$resource', 'Query', Widget]); .factory('Widget', ['$resource', 'Query', Widget]);
})(); })();

View 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>

View 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>

View 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>

View File

@@ -25,12 +25,12 @@
"marked": "~0.3.2", "marked": "~0.3.2",
"bucky": "~0.2.6", "bucky": "~0.2.6",
"pace": "~0.5.1", "pace": "~0.5.1",
"angular-ui-select": "0.8.2", "angular-ui-select": "~0.12.0",
"font-awesome": "~4.2.0", "font-awesome": "~4.2.0",
"mustache": "~1.0.0", "mustache": "~1.0.0",
"canvg": "gabelerner/canvg", "canvg": "gabelerner/canvg",
"angular-ui-bootstrap-bower": "~0.12.1", "angular-ui-bootstrap-bower": "~0.12.1",
"leaflet":"~0.7.3" "leaflet": "~0.7.3"
}, },
"devDependencies": { "devDependencies": {
"angular-mocks": "1.2.18", "angular-mocks": "1.2.18",

View File

@@ -2,6 +2,7 @@ import logging
import urlparse import urlparse
import redis import redis
from statsd import StatsClient from statsd import StatsClient
from flask_mail import Mail
from redash import settings from redash import settings
from redash.query_runner import import_query_runners from redash.query_runner import import_query_runners
@@ -32,6 +33,8 @@ def create_redis_connection():
setup_logging() setup_logging()
redis_connection = create_redis_connection() 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) statsd_client = StatsClient(host=settings.STATSD_HOST, port=settings.STATSD_PORT, prefix=settings.STATSD_PREFIX)
import_query_runners(settings.QUERY_RUNNERS) import_query_runners(settings.QUERY_RUNNERS)

View File

@@ -53,7 +53,8 @@ class PasswordHashField(fields.PasswordField):
class PgModelConverter(CustomModelConverter): class PgModelConverter(CustomModelConverter):
def __init__(self, view, additional=None): def __init__(self, view, additional=None):
additional = {ArrayField: self.handle_array_field, additional = {ArrayField: self.handle_array_field,
DateTimeTZField: self.handle_datetime_tz_field} DateTimeTZField: self.handle_datetime_tz_field,
}
super(PgModelConverter, self).__init__(view, additional) super(PgModelConverter, self).__init__(view, additional)
self.view = view self.view = view
@@ -104,13 +105,8 @@ class DataSourceModelView(BaseModelView):
def init_admin(app): def init_admin(app):
admin = Admin(app, name='re:dash admin') admin = Admin(app, name='re:dash admin')
views = { admin.add_view(UserModelView(models.User))
models.User: UserModelView(models.User), admin.add_view(DataSourceModelView(models.DataSource))
models.DataSource: DataSourceModelView(models.DataSource)
}
for m in models.all_models: for m in (models.QueryResult, models.Query, models.Dashboard, models.Visualization, models.Widget, models.ActivityLog, models.Group, models.Event):
if m in views: admin.add_view(BaseModelView(m))
admin.add_view(views[m])
else:
admin.add_view(BaseModelView(m))

View File

@@ -13,8 +13,9 @@ import logging
from flask import render_template, send_from_directory, make_response, request, jsonify, redirect, \ from flask import render_template, send_from_directory, make_response, request, jsonify, redirect, \
session, url_for, current_app, flash 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 flask_login import current_user, login_user, logout_user, login_required
from funcy import project
import sqlparse import sqlparse
from redash import statsd_client, models, settings, utils from redash import statsd_client, models, settings, utils
@@ -33,6 +34,8 @@ def ping():
@app.route('/admin/<anything>') @app.route('/admin/<anything>')
@app.route('/dashboard/<anything>') @app.route('/dashboard/<anything>')
@app.route('/alerts')
@app.route('/alerts/<pk>')
@app.route('/queries') @app.route('/queries')
@app.route('/queries/<query_id>') @app.route('/queries/<query_id>')
@app.route('/queries/<query_id>/<anything>') @app.route('/queries/<query_id>/<anything>')
@@ -575,6 +578,105 @@ class JobAPI(BaseResource):
api.add_resource(JobAPI, '/api/jobs/<job_id>', endpoint='job') 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>') @app.route('/<path:filename>')
def send_static(filename): def send_static(filename):
if current_app.debug: if current_app.debug:
@@ -583,7 +685,3 @@ def send_static(filename):
cache_timeout = None cache_timeout = None
return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout) return send_from_directory(settings.STATIC_ASSETS_PATH, filename, cache_timeout=cache_timeout)
if __name__ == '__main__':
app.run(debug=True)

View File

@@ -77,6 +77,17 @@ class BaseModel(peewee.Model):
super(BaseModel, self).save(*args, **kwargs) super(BaseModel, self).save(*args, **kwargs)
self.post_save(created) 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): class ModelTimestampsMixin(BaseModel):
updated_at = DateTimeTZField(default=datetime.datetime.now) updated_at = DateTimeTZField(default=datetime.datetime.now)
@@ -163,6 +174,7 @@ class User(ModelTimestampsMixin, BaseModel, UserMixin, PermissionsCheckMixin):
'id': self.id, 'id': self.id,
'name': self.name, 'name': self.name,
'email': self.email, 'email': self.email,
'gravatar_url': self.gravatar_url,
'updated_at': self.updated_at, 'updated_at': self.updated_at,
'created_at': self.created_at 'created_at': self.created_at
} }
@@ -177,6 +189,11 @@ class User(ModelTimestampsMixin, BaseModel, UserMixin, PermissionsCheckMixin):
if not self.api_key: if not self.api_key:
self.api_key = generate_token(40) 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 @property
def permissions(self): def permissions(self):
# TODO: this should be cached. # TODO: this should be cached.
@@ -280,6 +297,13 @@ class DataSource(BaseModel):
def all(cls): def all(cls):
return cls.select().order_by(cls.id.asc()) 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): class QueryResult(BaseModel):
id = peewee.PrimaryKeyField() id = peewee.PrimaryKeyField()
@@ -338,13 +362,17 @@ class QueryResult(BaseModel):
logging.info("Inserted query (%s) data; id=%s", query_hash, query_result.id) logging.info("Inserted query (%s) data; id=%s", query_hash, query_result.id)
updated_count = Query.update(latest_query_data=query_result).\ sql = "UPDATE queries SET latest_query_data_id = %s WHERE query_hash = %s AND data_source_id = %s RETURNING id"
where(Query.query_hash==query_hash, Query.data_source==data_source_id).\ query_ids = [row[0] for row in db.database.execute_sql(sql, params=(query_result.id, query_hash, data_source_id))]
execute()
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): def __unicode__(self):
return u"%d | %s | %s" % (self.id, self.query_hash, self.retrieved_at) 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) 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): class Dashboard(ModelTimestampsMixin, BaseModel):
id = peewee.PrimaryKeyField() id = peewee.PrimaryKeyField()
slug = peewee.CharField(max_length=140, index=True) slug = peewee.CharField(max_length=140, index=True)
@@ -716,7 +821,7 @@ class Event(BaseModel):
return event 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(): def init_db():

View File

@@ -40,6 +40,17 @@ def parse_boolean(str):
return json.loads(str.lower()) 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') NAME = os.environ.get('REDASH_NAME', 're:dash')
REDIS_URL = os.environ.get('REDASH_REDIS_URL', "redis://localhost:6379/0") 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")) CLIENT_SIDE_METRICS = parse_boolean(os.environ.get("REDASH_CLIENT_SIDE_METRICS", "false"))
ANALYTICS = os.environ.get("REDASH_ANALYTICS", "") 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). # 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 # 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). # to the calling domain (or domains in a comma separated list).

View File

@@ -1,10 +1,11 @@
import time import time
import logging import logging
from flask.ext.mail import Message
import redis import redis
from celery import Task from celery import Task
from celery.result import AsyncResult from celery.result import AsyncResult
from celery.utils.log import get_task_logger 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.utils import gen_query_hash
from redash.worker import celery from redash.worker import celery
from redash.query_runner import get_query_runner from redash.query_runner import get_query_runner
@@ -222,7 +223,7 @@ def cleanup_query_results():
@celery.task(base=BaseTask) @celery.task(base=BaseTask)
def refresh_schemas(): def refresh_schemas():
""" """
Refershs the datasources schema. Refreshs the datasources schema.
""" """
for ds in models.DataSource.all(): for ds in models.DataSource.all():
@@ -230,6 +231,39 @@ def refresh_schemas():
ds.get_schema(refresh=True) 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) @celery.task(bind=True, base=BaseTask, track_started=True)
def execute_query(self, query, data_source_id, metadata): def execute_query(self, query, data_source_id, metadata):
start_time = time.time() 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)) redis_connection.delete(QueryTask._job_lock_id(query_hash, data_source.id))
if not error: 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: else:
raise Exception(error) raise Exception(error)

View File

@@ -3,7 +3,7 @@ from flask import Flask, make_response
from werkzeug.wrappers import Response from werkzeug.wrappers import Response
from flask.ext.restful import Api from flask.ext.restful import Api
from redash import settings, utils from redash import settings, utils, mail
from redash.models import db from redash.models import db
from redash.admin import init_admin from redash.admin import init_admin
@@ -22,7 +22,9 @@ init_admin(app)
# configure our database # configure our database
settings.DATABASE_CONFIG.update({'threadlocals': True}) settings.DATABASE_CONFIG.update({'threadlocals': True})
app.config['DATABASE'] = settings.DATABASE_CONFIG app.config['DATABASE'] = settings.DATABASE_CONFIG
app.config.update(settings.all_settings())
db.init_app(app) db.init_app(app)
mail.init_app(app)
from redash.authentication import setup_authentication from redash.authentication import setup_authentication
setup_authentication(app) setup_authentication(app)

View File

@@ -3,6 +3,7 @@ Flask-Admin==1.1.0
Flask-RESTful==0.2.10 Flask-RESTful==0.2.10
Flask-Login==0.2.11 Flask-Login==0.2.11
Flask-OAuth==0.12 Flask-OAuth==0.12
flask-mail==0.9.1
passlib==1.6.2 passlib==1.6.2
Jinja2==2.7.2 Jinja2==2.7.2
MarkupSafe==0.18 MarkupSafe==0.18
@@ -29,4 +30,5 @@ click==3.3
RestrictedPython==3.6.0 RestrictedPython==3.6.0
wtf-peewee==0.2.3 wtf-peewee==0.2.3
pysaml2==2.4.0 pysaml2==2.4.0
pycrypto==2.6.1 pycrypto==2.6.1
funcy==1.5

View File

@@ -164,7 +164,7 @@ class QueryArchiveTest(BaseTestCase):
def test_archived_query_doesnt_return_in_all(self): def test_archived_query_doesnt_return_in_all(self):
query = query_factory.create(schedule="1") query = query_factory.create(schedule="1")
yesterday = datetime.datetime.now() - datetime.timedelta(days=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) 123, yesterday)
query.latest_query_data = query_result query.latest_query_data = query_result
@@ -329,7 +329,7 @@ class TestQueryResultStoreResult(BaseTestCase):
self.data = "data" self.data = "data"
def test_stores_the_result(self): 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.data, self.runtime, self.utcnow)
self.assertEqual(query_result.data, self.data) 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) query2 = query_factory.create(query=self.query, data_source=self.data_source)
query3 = 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.runtime, self.utcnow)
self.assertEqual(models.Query.get_by_id(query1.id)._data['latest_query_data'], query_result.id) 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) 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) 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.runtime, self.utcnow)
self.assertEqual(models.Query.get_by_id(query1.id)._data['latest_query_data'], query_result.id) 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) 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()) 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.runtime, self.utcnow)
self.assertEqual(models.Query.get_by_id(query1.id)._data['latest_query_data'], query_result.id) self.assertEqual(models.Query.get_by_id(query1.id)._data['latest_query_data'], query_result.id)