diff --git a/caravel/templates/appbuilder/navbar_right.html b/caravel/templates/appbuilder/navbar_right.html
index 433bf1bf48db2..5b1ecd560d9c3 100644
--- a/caravel/templates/appbuilder/navbar_right.html
+++ b/caravel/templates/appbuilder/navbar_right.html
@@ -1,15 +1,14 @@
-{% macro locale_menu(languages) %}
{% set locale = session['locale'] %}
{% if not locale %}
{% set locale = 'en' %}
{% endif %}
+{% if languages.keys()|length > 1 %}
- {% if languages.keys()|length > 1 %}
- {% endif %}
-{% endmacro %}
+{% endif %}
diff --git a/caravel/templates/caravel/basic.html b/caravel/templates/caravel/basic.html
index eb62dc21560a4..72b8485cb5f49 100644
--- a/caravel/templates/caravel/basic.html
+++ b/caravel/templates/caravel/basic.html
@@ -9,6 +9,7 @@
{% block head_css %}
+
{% endblock %}
{% block head_js %}
diff --git a/caravel/templates/caravel/welcome.html b/caravel/templates/caravel/welcome.html
index 4d614a2c00fa2..492876268ddaa 100644
--- a/caravel/templates/caravel/welcome.html
+++ b/caravel/templates/caravel/welcome.html
@@ -5,12 +5,12 @@
{% endblock %}
-{% block title %}Welcome!{% endblock %}
+{% block title %}{{ _("Welcome!") }}{% endblock %}
{% block body %}
diff --git a/caravel/translations/es/LC_MESSAGES/messages.po b/caravel/translations/es/LC_MESSAGES/messages.po
new file mode 100644
index 0000000000000..60ee1034f49f2
--- /dev/null
+++ b/caravel/translations/es/LC_MESSAGES/messages.po
@@ -0,0 +1,118 @@
+# Spanish translations for PROJECT.
+# Copyright (C) 2016 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR
, 2016.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2016-05-02 00:21-0700\n"
+"PO-Revision-Date: 2016-05-02 08:49-0700\n"
+"Last-Translator: FULL NAME \n"
+"Language: es\n"
+"Language-Team: es \n"
+"Plural-Forms: nplurals=2; plural=(n != 1)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.3.4\n"
+
+#: caravel/models.py:564
+msgid ""
+"Datetime column not provided as part table configuration and is required "
+"by this type of chart"
+msgstr ""
+
+#: caravel/models.py:1153
+msgid "No data was returned."
+msgstr ""
+
+#: caravel/views.py:116
+msgid ""
+"Whether to make this column available as a [Time Granularity] option, "
+"column has to be DATETIME or DATETIME-like"
+msgstr ""
+
+#: caravel/views.py:215
+msgid "Databases"
+msgstr ""
+
+#: caravel/views.py:217 caravel/views.py:261 caravel/views.py:284
+msgid "Sources"
+msgstr ""
+
+#: caravel/views.py:260
+msgid "Tables"
+msgstr ""
+
+#: caravel/views.py:282
+msgid "Druid Clusters"
+msgstr ""
+
+#: caravel/views.py:313
+msgid "Slices"
+msgstr ""
+
+#: caravel/views.py:341
+msgid ""
+"This json object describes the positioning of the widgets in the "
+"dashboard. It is dynamically generated when adjusting the widgets size "
+"and positions by using drag & drop in the dashboard view"
+msgstr ""
+
+#: caravel/views.py:346
+msgid ""
+"The css for individual dashboards can be altered here, or in the "
+"dashboard view where changes are immediately visible"
+msgstr ""
+
+#: caravel/views.py:367
+msgid "Dashboards"
+msgstr ""
+
+#: caravel/views.py:392
+msgid "Action Log"
+msgstr ""
+
+#: caravel/views.py:393
+msgid "Security"
+msgstr ""
+
+#: caravel/views.py:430
+msgid "Druid Datasources"
+msgstr ""
+
+#: caravel/views.py:514
+msgid "The datasource seems to have been deleted"
+msgstr ""
+
+#: caravel/views.py:522
+msgid "You don't seem to have access to this datasource"
+msgstr ""
+
+#: caravel/views.py:843
+msgid "This view requires the `all_datasource_access` permission"
+msgstr ""
+
+#: caravel/views.py:954
+msgid "CSS Templates"
+msgstr ""
+
+#: caravel/templates/appbuilder/navbar_right.html:34
+msgid "Profile"
+msgstr ""
+
+#: caravel/templates/appbuilder/navbar_right.html:35
+msgid "Logout"
+msgstr ""
+
+#: caravel/templates/appbuilder/navbar_right.html:40
+msgid "Login"
+msgstr ""
+
+#: caravel/templates/caravel/welcome.html:8
+#: caravel/templates/caravel/welcome.html:13
+msgid "Welcome!"
+msgstr ""
+
diff --git a/caravel/translations/fr/LC_MESSAGES/messages.mo b/caravel/translations/fr/LC_MESSAGES/messages.mo
new file mode 100644
index 0000000000000..5a7c0e9bf19d2
Binary files /dev/null and b/caravel/translations/fr/LC_MESSAGES/messages.mo differ
diff --git a/caravel/translations/fr/LC_MESSAGES/messages.po b/caravel/translations/fr/LC_MESSAGES/messages.po
new file mode 100644
index 0000000000000..4fad2485ec856
--- /dev/null
+++ b/caravel/translations/fr/LC_MESSAGES/messages.po
@@ -0,0 +1,118 @@
+# French translations for PROJECT.
+# Copyright (C) 2016 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR , 2016.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2016-05-02 00:21-0700\n"
+"PO-Revision-Date: 2016-05-01 23:07-0700\n"
+"Last-Translator: FULL NAME \n"
+"Language: fr\n"
+"Language-Team: fr \n"
+"Plural-Forms: nplurals=2; plural=(n > 1)\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.3.4\n"
+
+#: caravel/models.py:564
+msgid ""
+"Datetime column not provided as part table configuration and is required "
+"by this type of chart"
+msgstr ""
+
+#: caravel/models.py:1153
+msgid "No data was returned."
+msgstr ""
+
+#: caravel/views.py:116
+msgid ""
+"Whether to make this column available as a [Time Granularity] option, "
+"column has to be DATETIME or DATETIME-like"
+msgstr ""
+
+#: caravel/views.py:215
+msgid "Databases"
+msgstr ""
+
+#: caravel/views.py:217 caravel/views.py:261 caravel/views.py:284
+msgid "Sources"
+msgstr ""
+
+#: caravel/views.py:260
+msgid "Tables"
+msgstr ""
+
+#: caravel/views.py:282
+msgid "Druid Clusters"
+msgstr ""
+
+#: caravel/views.py:313
+msgid "Slices"
+msgstr ""
+
+#: caravel/views.py:341
+msgid ""
+"This json object describes the positioning of the widgets in the "
+"dashboard. It is dynamically generated when adjusting the widgets size "
+"and positions by using drag & drop in the dashboard view"
+msgstr ""
+
+#: caravel/views.py:346
+msgid ""
+"The css for individual dashboards can be altered here, or in the "
+"dashboard view where changes are immediately visible"
+msgstr ""
+
+#: caravel/views.py:367
+msgid "Dashboards"
+msgstr ""
+
+#: caravel/views.py:392
+msgid "Action Log"
+msgstr ""
+
+#: caravel/views.py:393
+msgid "Security"
+msgstr "Securité"
+
+#: caravel/views.py:430
+msgid "Druid Datasources"
+msgstr ""
+
+#: caravel/views.py:514
+msgid "The datasource seems to have been deleted"
+msgstr ""
+
+#: caravel/views.py:522
+msgid "You don't seem to have access to this datasource"
+msgstr ""
+
+#: caravel/views.py:843
+msgid "This view requires the `all_datasource_access` permission"
+msgstr ""
+
+#: caravel/views.py:954
+msgid "CSS Templates"
+msgstr ""
+
+#: caravel/templates/appbuilder/navbar_right.html:34
+msgid "Profile"
+msgstr ""
+
+#: caravel/templates/appbuilder/navbar_right.html:35
+msgid "Logout"
+msgstr ""
+
+#: caravel/templates/appbuilder/navbar_right.html:40
+msgid "Login"
+msgstr ""
+
+#: caravel/templates/caravel/welcome.html:8
+#: caravel/templates/caravel/welcome.html:13
+msgid "Welcome!"
+msgstr "Bienvenue!"
+
diff --git a/caravel/translations/zh/LC_MESSAGES/messages.mo b/caravel/translations/zh/LC_MESSAGES/messages.mo
new file mode 100644
index 0000000000000..397a1eee9f858
Binary files /dev/null and b/caravel/translations/zh/LC_MESSAGES/messages.mo differ
diff --git a/caravel/translations/zh/LC_MESSAGES/messages.po b/caravel/translations/zh/LC_MESSAGES/messages.po
new file mode 100644
index 0000000000000..3a9bdece36212
--- /dev/null
+++ b/caravel/translations/zh/LC_MESSAGES/messages.po
@@ -0,0 +1,117 @@
+# Chinese translations for PROJECT.
+# Copyright (C) 2016 ORGANIZATION
+# This file is distributed under the same license as the PROJECT project.
+# FIRST AUTHOR , 2016.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PROJECT VERSION\n"
+"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
+"POT-Creation-Date: 2016-05-02 00:21-0700\n"
+"PO-Revision-Date: 2016-05-01 23:07-0700\n"
+"Last-Translator: FULL NAME \n"
+"Language: zh\n"
+"Language-Team: zh \n"
+"Plural-Forms: nplurals=1; plural=0\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Babel 2.3.4\n"
+
+#: caravel/models.py:564
+msgid ""
+"Datetime column not provided as part table configuration and is required "
+"by this type of chart"
+msgstr "所选表格需要日期时间但在表格配置文件中没有被提供"
+
+#: caravel/models.py:1153
+msgid "No data was returned."
+msgstr "所选数据为空"
+
+#: caravel/views.py:116
+msgid ""
+"Whether to make this column available as a [Time Granularity] option, "
+"column has to be DATETIME or DATETIME-like"
+msgstr ""
+
+#: caravel/views.py:215
+msgid "Databases"
+msgstr "数据库"
+
+#: caravel/views.py:217 caravel/views.py:261 caravel/views.py:284
+msgid "Sources"
+msgstr "源"
+
+#: caravel/views.py:260
+msgid "Tables"
+msgstr "表格"
+
+#: caravel/views.py:282
+msgid "Druid Clusters"
+msgstr "Druid簇"
+
+#: caravel/views.py:313
+msgid "Slices"
+msgstr "切片"
+
+#: caravel/views.py:341
+msgid ""
+"This json object describes the positioning of the widgets in the "
+"dashboard. It is dynamically generated when adjusting the widgets size "
+"and positions by using drag & drop in the dashboard view"
+msgstr ""
+
+#: caravel/views.py:346
+msgid ""
+"The css for individual dashboards can be altered here, or in the "
+"dashboard view where changes are immediately visible"
+msgstr ""
+
+#: caravel/views.py:367
+msgid "Dashboards"
+msgstr "仪表盘"
+
+#: caravel/views.py:392
+msgid "Action Log"
+msgstr "行动记录"
+
+#: caravel/views.py:393
+msgid "Security"
+msgstr "权限"
+
+#: caravel/views.py:430
+msgid "Druid Datasources"
+msgstr "Druid数据源"
+
+#: caravel/views.py:514
+msgid "The datasource seems to have been deleted"
+msgstr "此数据源好像已被删除"
+
+#: caravel/views.py:522
+msgid "You don't seem to have access to this datasource"
+msgstr "看来您不能读取此数据源"
+
+#: caravel/views.py:843
+msgid "This view requires the `all_datasource_access` permission"
+msgstr "此视图需要`all_datasource_access`权限"
+
+#: caravel/views.py:954
+msgid "CSS Templates"
+msgstr "CSS模板"
+
+#: caravel/templates/appbuilder/navbar_right.html:34
+msgid "Profile"
+msgstr "个人资料"
+
+#: caravel/templates/appbuilder/navbar_right.html:35
+msgid "Logout"
+msgstr "登出"
+
+#: caravel/templates/appbuilder/navbar_right.html:40
+msgid "Login"
+msgstr "登录"
+
+#: caravel/templates/caravel/welcome.html:8
+#: caravel/templates/caravel/welcome.html:13
+msgid "Welcome!"
+msgstr "欢迎"
diff --git a/caravel/utils.py b/caravel/utils.py
index b473a9f9f0021..7e3d4c079f500 100644
--- a/caravel/utils.py
+++ b/caravel/utils.py
@@ -154,6 +154,7 @@ def init(caravel):
sm = caravel.appbuilder.sm
alpha = sm.add_role("Alpha")
admin = sm.add_role("Admin")
+ config = caravel.app.config
merge_perm(sm, 'all_datasource_access', 'all_datasource_access')
@@ -167,24 +168,28 @@ def init(caravel):
sm.add_permission_role(alpha, perm)
sm.add_permission_role(admin, perm)
gamma = sm.add_role("Gamma")
+ public_role = sm.find_role("Public")
+ public_role_like_gamma = \
+ public_role and config.get('PUBLIC_ROLE_LIKE_GAMMA', False)
for perm in perms:
- if(
- perm.view_menu and perm.view_menu.name not in (
- 'ResetPasswordView',
- 'RoleModelView',
- 'UserDBModelView',
- 'Security') and
- perm.permission.name not in (
- 'all_datasource_access',
- 'can_add',
- 'can_download',
- 'can_delete',
- 'can_edit',
- 'can_save',
- 'datasource_access',
- 'muldelete',
- )):
+ if (perm.view_menu and perm.view_menu.name not in (
+ 'ResetPasswordView',
+ 'RoleModelView',
+ 'UserDBModelView',
+ 'Security') and
+ perm.permission.name not in (
+ 'all_datasource_access',
+ 'can_add',
+ 'can_download',
+ 'can_delete',
+ 'can_edit',
+ 'can_save',
+ 'datasource_access',
+ 'muldelete',
+ )):
sm.add_permission_role(gamma, perm)
+ if public_role_like_gamma:
+ sm.add_permission_role(public_role, perm)
session = db.session()
table_perms = [
table.perm for table in session.query(models.SqlaTable).all()]
diff --git a/caravel/version.py b/caravel/version.py
index 3913c568f8d1e..c5f137e750751 100644
--- a/caravel/version.py
+++ b/caravel/version.py
@@ -1,6 +1,6 @@
VERSION_MAJOR = 0
-VERSION_MINOR = 8
-VERSION_BUILD = 8
+VERSION_MINOR = 9
+VERSION_BUILD = 0
VERSION_INFO = (VERSION_MAJOR, VERSION_MINOR, VERSION_BUILD)
VERSION_STRING = "%d.%d.%d" % VERSION_INFO
diff --git a/caravel/views.py b/caravel/views.py
index 6908e086a08c9..f451695ba3ea6 100644
--- a/caravel/views.py
+++ b/caravel/views.py
@@ -13,15 +13,18 @@
import pandas as pd
import sqlalchemy as sqla
+
from flask import (
g, request, redirect, flash, Response, render_template, Markup)
from flask.ext.appbuilder import ModelView, CompactCRUDMixin, BaseView, expose
from flask.ext.appbuilder.actions import action
from flask.ext.appbuilder.models.sqla.interface import SQLAInterface
from flask.ext.appbuilder.security.decorators import has_access
+from flask.ext.babelpkg import gettext as _
+from flask_appbuilder.models.sqla.filters import BaseFilter
+
from pydruid.client import doublesum
-from sqlalchemy import create_engine
-from sqlalchemy import select, text
+from sqlalchemy import create_engine, select, text
from sqlalchemy.sql.expression import TextAsFrom
from werkzeug.routing import BaseConverter
from wtforms.validators import ValidationError
@@ -32,6 +35,48 @@
log_this = models.Log.log_this
+def get_user_roles():
+ if g.user.is_anonymous():
+ return [appbuilder.sm.find_role('Public')]
+ return g.user.roles
+
+
+class CaravelFilter(BaseFilter):
+ def get_perms(self):
+ perms = []
+ for role in get_user_roles():
+ for perm_view in role.permissions:
+ if perm_view.permission.name == 'datasource_access':
+ perms.append(perm_view.view_menu.name)
+ return perms
+
+
+class FilterSlice(CaravelFilter):
+ def apply(self, query, func): # noqa
+ if any([r.name in ('Admin', 'Alpha') for r in get_user_roles()]):
+ return query
+ qry = query.filter(self.model.perm.in_(self.get_perms()))
+ print(qry)
+ return qry
+
+
+class FilterDashboard(CaravelFilter):
+ def apply(self, query, func): # noqa
+ if any([r.name in ('Admin', 'Alpha') for r in get_user_roles()]):
+ return query
+ Slice = models.Slice # noqa
+ slice_ids_qry = (
+ db.session
+ .query(Slice.id)
+ .filter(Slice.perm.in_(self.get_perms()))
+ )
+ return query.filter(
+ self.model.slices.any(
+ models.Slice.id.in_(slice_ids_qry)
+ )
+ )
+
+
def validate_json(form, field): # noqa
try:
json.loads(field.data)
@@ -66,18 +111,19 @@ class TableColumnInlineView(CompactCRUDMixin, CaravelModelView): # noqa
datamodel = SQLAInterface(models.TableColumn)
can_delete = False
edit_columns = [
- 'column_name', 'description', 'groupby', 'filterable', 'table',
- 'count_distinct', 'sum', 'min', 'max', 'expression', 'is_dttm']
+ 'column_name', 'verbose_name', 'description', 'groupby', 'filterable',
+ 'table', 'count_distinct', 'sum', 'min', 'max', 'expression',
+ 'is_dttm', ]
add_columns = edit_columns
list_columns = [
'column_name', 'type', 'groupby', 'filterable', 'count_distinct',
'sum', 'min', 'max', 'is_dttm']
page_size = 500
description_columns = {
- 'is_dttm': (
+ 'is_dttm': (_(
"Whether to make this column available as a "
"[Time Granularity] option, column has to be DATETIME or "
- "DATETIME-like"),
+ "DATETIME-like")),
'expression': utils.markdown(
"a valid SQL expression as supported by the underlying backend. "
"Example: `substr(name, 1, 1)`", True),
@@ -123,21 +169,26 @@ class DruidMetricInlineView(CompactCRUDMixin, CaravelModelView): # noqa
datamodel = SQLAInterface(models.DruidMetric)
list_columns = ['metric_name', 'verbose_name', 'metric_type']
edit_columns = [
- 'metric_name', 'description', 'verbose_name', 'metric_type',
- 'datasource', 'json']
- add_columns = [
- 'metric_name', 'verbose_name', 'metric_type', 'datasource', 'json']
+ 'metric_name', 'description', 'verbose_name', 'metric_type', 'json',
+ 'datasource']
+ add_columns = edit_columns
page_size = 500
validators_columns = {
'json': [validate_json],
}
+ description_columns = {
+ 'metric_type': utils.markdown(
+ "use `postagg` as the metric type if you are defining a "
+ "[Druid Post Aggregation]"
+ "(http://druid.io/docs/latest/querying/post-aggregations.html)",
+ True),
+ }
appbuilder.add_view_no_menu(DruidMetricInlineView)
class DatabaseView(CaravelModelView, DeleteMixin): # noqa
datamodel = SQLAInterface(models.Database)
- list_columns = ['database_name', 'sql_link', 'created_by_', 'changed_on']
- order_columns = utils.list_minus(list_columns, ['created_by_'])
+ list_columns = ['database_name', 'sql_link', 'creator', 'changed_on_']
add_columns = [
'database_name', 'sqlalchemy_uri', 'cache_timeout', 'extra']
search_exclude_columns = ('password',)
@@ -174,8 +225,9 @@ def pre_update(self, db):
appbuilder.add_view(
DatabaseView,
"Databases",
+ label=_("Databases"),
icon="fa-database",
- category="Sources",
+ category=_("Sources"),
category_icon='fa-database',)
@@ -183,7 +235,7 @@ class TableModelView(CaravelModelView, DeleteMixin): # noqa
datamodel = SQLAInterface(models.SqlaTable)
list_columns = [
'table_link', 'database', 'sql_link', 'is_featured',
- 'changed_by_', 'changed_on']
+ 'changed_by_', 'changed_on_']
add_columns = [
'table_name', 'database', 'schema',
'default_endpoint', 'offset', 'cache_timeout']
@@ -218,8 +270,8 @@ def post_update(self, table):
appbuilder.add_view(
TableModelView,
- "Tables",
- category="Sources",
+ _("Tables"),
+ category=_("Sources"),
icon='fa-table',)
@@ -240,27 +292,25 @@ class DruidClusterModelView(CaravelModelView, DeleteMixin): # noqa
if config['DRUID_IS_ACTIVE']:
appbuilder.add_view(
DruidClusterModelView,
- "Druid Clusters",
+ _("Druid Clusters"),
icon="fa-cubes",
- category="Sources",
+ category=_("Sources"),
category_icon='fa-database',)
+
class SliceModelView(CaravelModelView, DeleteMixin): # noqa
datamodel = SQLAInterface(models.Slice)
add_template = "caravel/add_slice.html"
can_add = False
label_columns = {
- 'created_by_': 'Creator',
'datasource_link': 'Datasource',
}
list_columns = [
- 'slice_link', 'viz_type',
- 'datasource_link', 'created_by_', 'modified']
- order_columns = utils.list_minus(list_columns, ['created_by_', 'modified'])
+ 'slice_link', 'viz_type', 'datasource_link', 'creator', 'modified']
edit_columns = [
'slice_name', 'description', 'viz_type', 'druid_datasource',
- 'table', 'dashboards', 'params', 'cache_timeout']
+ 'table', 'owners', 'dashboards', 'params', 'cache_timeout']
base_order = ('changed_on', 'desc')
description_columns = {
'description': Markup(
@@ -269,10 +319,11 @@ class SliceModelView(CaravelModelView, DeleteMixin): # noqa
""
"markdown"),
}
+ base_filters = [['id', FilterSlice, lambda: []]]
appbuilder.add_view(
SliceModelView,
- "Slices",
+ _("Slices"),
icon="fa-bar-chart",
category="",
category_icon='',)
@@ -281,10 +332,9 @@ class SliceModelView(CaravelModelView, DeleteMixin): # noqa
class SliceAsync(SliceModelView): # noqa
list_columns = [
'slice_link', 'viz_type',
- 'created_by_', 'modified', 'icons']
+ 'creator', 'modified', 'icons']
label_columns = {
'icons': ' ',
- 'created_by_': 'Creator',
'viz_type': 'Type',
'slice_link': 'Slice',
}
@@ -294,28 +344,25 @@ class SliceAsync(SliceModelView): # noqa
class DashboardModelView(CaravelModelView, DeleteMixin): # noqa
datamodel = SQLAInterface(models.Dashboard)
- label_columns = {
- 'created_by_': 'Creator',
- }
- list_columns = ['dashboard_link', 'created_by_', 'modified']
- order_columns = utils.list_minus(list_columns, ['created_by_', 'modified'])
+ list_columns = ['dashboard_link', 'creator', 'modified']
edit_columns = [
- 'dashboard_title', 'slug', 'slices', 'position_json', 'css',
+ 'dashboard_title', 'slug', 'slices', 'owners', 'position_json', 'css',
'json_metadata']
add_columns = edit_columns
base_order = ('changed_on', 'desc')
description_columns = {
- 'position_json': (
+ 'position_json': _(
"This json object describes the positioning of the widgets in "
"the dashboard. It is dynamically generated when adjusting "
"the widgets size and positions by using drag & drop in "
"the dashboard view"),
- 'css': (
+ 'css': _(
"The css for individual dashboards can be altered here, or "
"in the dashboard view where changes are immediately "
"visible"),
'slug': "To get a readable URL for your dashboard",
}
+ base_filters = [['slice', FilterDashboard, lambda: []]]
def pre_add(self, obj):
obj.slug = obj.slug.strip() or None
@@ -330,15 +377,16 @@ def pre_update(self, obj):
appbuilder.add_view(
DashboardModelView,
"Dashboards",
+ label=_("Dashboards"),
icon="fa-dashboard",
+
category="",
category_icon='',)
class DashboardModelViewAsync(DashboardModelView): # noqa
- list_columns = ['dashboard_link', 'created_by_', 'modified']
+ list_columns = ['dashboard_link', 'creator', 'modified']
label_columns = {
- 'created_by_': 'Creator',
'dashboard_link': 'Dashboard',
}
@@ -354,19 +402,16 @@ class LogModelView(CaravelModelView):
appbuilder.add_view(
LogModelView,
"Action Log",
- category="Security",
+ label=_("Action Log"),
+ category=_("Security"),
icon="fa-list-ol")
class DruidDatasourceModelView(CaravelModelView, DeleteMixin): # noqa
datamodel = SQLAInterface(models.DruidDatasource)
list_columns = [
- 'datasource_link', 'cluster', 'owner',
- 'created_by_', 'created_on',
- 'changed_by_', 'changed_on',
- 'offset']
- related_views = [
- DruidColumnInlineView, DruidMetricInlineView]
+ 'datasource_link', 'cluster', 'changed_by_', 'modified', 'offset']
+ related_views = [DruidColumnInlineView, DruidMetricInlineView]
edit_columns = [
'datasource_name', 'cluster', 'description', 'owner',
'is_featured', 'is_hidden', 'default_endpoint', 'offset',
@@ -392,6 +437,7 @@ def post_update(self, datasource):
appbuilder.add_view(
DruidDatasourceModelView,
"Druid Datasources",
+ label=_("Druid Datasources"),
category="Sources",
icon="fa-cube")
@@ -475,7 +521,7 @@ def explore(self, datasource_type, datasource_id):
.first()
)
if not datasource:
- flash("The datasource seems to have been deleted", "alert")
+ flash(_("The datasource seems to have been deleted"), "alert")
return redirect(error_redirect)
all_datasource_access = self.appbuilder.sm.has_access(
@@ -483,7 +529,7 @@ def explore(self, datasource_type, datasource_id):
datasource_access = self.appbuilder.sm.has_access(
'datasource_access', datasource.perm)
if not (all_datasource_access or datasource_access):
- flash("You don't seem to have access to this datasource", "danger")
+ flash(_("You don't seem to have access to this datasource"), "danger")
return redirect(error_redirect)
action = request.args.get('action')
@@ -506,14 +552,16 @@ def explore(self, datasource_type, datasource_id):
return redirect(error_redirect)
if request.args.get("json") == "true":
status = 200
- try:
+ if config.get("DEBUG"):
+ # Allows for nice debugger stack traces in debug mode
payload = obj.get_json()
- except Exception as e:
- logging.exception(e)
- if config.get("DEBUG"):
- raise e
- payload = str(e)
- status = 500
+ else:
+ try:
+ payload = obj.get_json()
+ except Exception as e:
+ logging.exception(e)
+ payload = str(e)
+ status = 500
resp = Response(
payload,
status=status,
@@ -682,12 +730,12 @@ def favstar(self, class_name, obj_id, action):
FavStar = models.FavStar # noqa
count = 0
favs = session.query(FavStar).filter_by(
- class_name=class_name, obj_id=obj_id, user_id=g.user.id).all()
+ class_name=class_name, obj_id=obj_id, user_id=g.user.get_id()).all()
if action == 'select':
if not favs:
session.add(
FavStar(
- class_name=class_name, obj_id=obj_id, user_id=g.user.id,
+ class_name=class_name, obj_id=obj_id, user_id=g.user.get_id(),
dttm=datetime.now()))
count = 1
elif action == 'unselect':
@@ -804,8 +852,8 @@ def runsql(self):
if (
not self.appbuilder.sm.has_access(
'all_datasource_access', 'all_datasource_access')):
- raise Exception(
- "This view requires the `all_datasource_access` permission")
+ raise Exception(_(
+ "This view requires the `all_datasource_access` permission"))
content = ""
if mydb:
eng = mydb.get_sqla_engine()
@@ -915,6 +963,7 @@ class CssTemplateModelView(CaravelModelView, DeleteMixin):
appbuilder.add_view(
CssTemplateModelView,
"CSS Templates",
+ label=_("CSS Templates"),
icon="fa-css3",
category="Sources",
category_icon='')
diff --git a/caravel/viz.py b/caravel/viz.py
index 4839899948426..3e15b1163bb40 100644
--- a/caravel/viz.py
+++ b/caravel/viz.py
@@ -8,6 +8,7 @@
from __future__ import print_function
from __future__ import unicode_literals
+import copy
import hashlib
import json
import logging
@@ -22,6 +23,7 @@
from six import string_types
from werkzeug.datastructures import ImmutableMultiDict
from werkzeug.urls import Href
+from dateutil import relativedelta as rdelta
from caravel import app, utils, cache
from caravel.forms import FormFactory
@@ -286,7 +288,7 @@ def data(self):
def get_csv(self):
df = self.get_df()
include_index = not isinstance(df.index, pd.RangeIndex)
- return df.to_csv(index=include_index)
+ return df.to_csv(index=include_index, encoding="utf-8")
def get_data(self):
return []
@@ -321,22 +323,25 @@ class TableViz(BaseViz):
verbose_name = "Table View"
credits = 'a Caravel original'
fieldsets = ({
- 'label': "Chart Options",
- 'fields': (
- 'row_limit',
- ('include_search', None),
- )
- }, {
'label': "GROUP BY",
+ 'description': 'Use this section if you want a query that aggregates',
'fields': (
'groupby',
'metrics',
)
}, {
'label': "NOT GROUPED BY",
+ 'description': 'Use this section if you want to query atomic rows',
'fields': (
'all_columns',
)
+ }, {
+ 'label': "Options",
+ 'fields': (
+ 'table_timestamp_format',
+ 'row_limit',
+ ('include_search', None),
+ )
})
is_timeseries = False
@@ -501,6 +506,19 @@ class TreemapViz(BaseViz):
verbose_name = "Treemap"
credits = 'd3.js'
is_timeseries = False
+ fieldsets = ({
+ 'label': None,
+ 'fields': (
+ 'metrics',
+ 'groupby',
+ ),
+ }, {
+ 'label': 'Chart Options',
+ 'fields': (
+ 'treemap_ratio',
+ 'number_format',
+ )
+ },)
def get_df(self, query_obj=None):
df = super(TreemapViz, self).get_df(query_obj)
@@ -524,6 +542,67 @@ def get_data(self):
return chart_data
+class CalHeatmapViz(BaseViz):
+
+ """Calendar heatmap."""
+
+ viz_type = "cal_heatmap"
+ verbose_name = "Calender Heatmap"
+ credits = (
+ 'cal-heatmap')
+ is_timeseries = True
+ fieldsets = ({
+ 'label': None,
+ 'fields': (
+ 'metric',
+ 'domain_granularity',
+ 'subdomain_granularity',
+ ),
+ },)
+
+ def get_df(self, query_obj=None):
+ df = super(CalHeatmapViz, self).get_df(query_obj)
+ return df
+
+ def get_data(self):
+ df = self.get_df()
+ form_data = self.form_data
+
+ df.columns = ["timestamp", "metric"]
+ timestamps = {str(obj["timestamp"].value / 10**9):
+ obj.get("metric") for obj in df.to_dict("records")}
+
+ start = utils.parse_human_datetime(form_data.get("since"))
+ end = utils.parse_human_datetime(form_data.get("until"))
+ domain = form_data.get("domain_granularity")
+ diff_delta = rdelta.relativedelta(end, start)
+ diff_secs = (end - start).total_seconds()
+
+ if domain == "year":
+ range_ = diff_delta.years + 1
+ elif domain == "month":
+ range_ = diff_delta.years * 12 + diff_delta.months + 1
+ elif domain == "week":
+ range_ = diff_delta.years * 53 + diff_delta.weeks + 1
+ elif domain == "day":
+ range_ = diff_secs // (24*60*60) + 1
+ else:
+ range_ = diff_secs // (60*60) + 1
+
+ return {
+ "timestamps": timestamps,
+ "start": start,
+ "domain": domain,
+ "subdomain": form_data.get("subdomain_granularity"),
+ "range": range_,
+ }
+
+ def query_obj(self):
+ qry = super(CalHeatmapViz, self).query_obj()
+ qry["metrics"] = [self.form_data["metric"]]
+ return qry
+
+
class NVD3Viz(BaseViz):
"""Base class for all nvd3 vizs"""
@@ -896,6 +975,15 @@ def get_df(self, query_obj=None):
return df
def to_series(self, df, classed='', title_suffix=''):
+ cols = []
+ for col in df.columns:
+ if col == '':
+ cols.append('N/A')
+ elif col == None:
+ cols.append('NULL')
+ else:
+ cols.append(col)
+ df.columns = cols
series = df.to_dict('series')
chart_data = []
@@ -918,7 +1006,10 @@ def to_series(self, df, classed='', title_suffix=''):
d = {
"key": series_title,
"classed": classed,
- "values": [{'x': ds, 'y': ys[ds]} for ds in df.timestamp],
+ "values": [
+ {'x': ds, 'y': ys[ds] if ds in ys else None}
+ for ds in df.timestamp
+ ],
}
chart_data.append(d)
return chart_data
@@ -1435,14 +1526,14 @@ class ParallelCoordinatesViz(BaseViz):
'metrics',
'secondary_metric',
'limit',
- ('show_datatable', None),
+ ('show_datatable', 'include_series'),
)
},)
def query_obj(self):
d = super(ParallelCoordinatesViz, self).query_obj()
fd = self.form_data
- d['metrics'] = fd.get('metrics')
+ d['metrics'] = copy.copy(fd.get('metrics'))
second = fd.get('secondary_metric')
if second not in d['metrics']:
d['metrics'] += [second]
@@ -1451,7 +1542,6 @@ def query_obj(self):
def get_data(self):
df = self.get_df()
- df = df[[self.form_data.get('series')] + self.form_data.get('metrics')]
return df.to_dict(orient="records")
@@ -1520,6 +1610,25 @@ def get_data(self):
return df.to_dict(orient="records")
+class HorizonViz(NVD3TimeSeriesViz):
+
+ """Horizon chart
+
+ https://www.npmjs.com/package/d3-horizon-chart
+ """
+
+ viz_type = "horizon"
+ verbose_name = "Horizon Charts"
+ credits = (
+ ''
+ 'd3-horizon-chart')
+ fieldsets = [NVD3TimeSeriesViz.fieldsets[0]] + [{
+ 'label': 'Chart Options',
+ 'fields': (
+ ('series_height', 'horizon_color_scale'),
+ ), }]
+
+
viz_types_list = [
TableViz,
PivotTableViz,
@@ -1544,6 +1653,8 @@ def get_data(self):
HeatmapViz,
BoxPlotViz,
TreemapViz,
+ CalHeatmapViz,
+ HorizonViz,
]
viz_types = OrderedDict([(v.viz_type, v) for v in viz_types_list
diff --git a/docs/gallery.rst b/docs/gallery.rst
index 86a6309b5b8eb..d44f184f28596 100644
--- a/docs/gallery.rst
+++ b/docs/gallery.rst
@@ -70,3 +70,9 @@ Gallery
.. image:: _static/img/viz_thumbnails/treemap.png
:scale: 25 %
+.. image:: _static/img/viz_thumbnails/cal_heatmap.png
+ :scale: 25 %
+
+.. image:: _static/img/viz_thumbnails/horizon.png
+ :scale: 25 %
+
diff --git a/pypi_push.sh b/pypi_push.sh
new file mode 100644
index 0000000000000..ab234f83391be
--- /dev/null
+++ b/pypi_push.sh
@@ -0,0 +1,6 @@
+cd caravel/assets/
+npm run prod
+cd ../..
+python setup.py register
+python setup.py sdist upload
+
diff --git a/setup.py b/setup.py
index a630c0561d0a5..61cd097edffa8 100644
--- a/setup.py
+++ b/setup.py
@@ -16,8 +16,10 @@
scripts=['caravel/bin/caravel'],
install_requires=[
'alembic>=0.8.5, <0.9.0',
+ 'babel==2.3.4',
'cryptography>=1.1.1, <2.0.0',
'flask-appbuilder>=1.6.0, <2.0.0',
+ 'Flask-BabelPkg==0.9.6',
'flask-cache>=0.13.1, <0.14.0',
'flask-migrate>=1.5.1, <2.0.0',
'flask-script>=2.0.5, <3.0.0',
diff --git a/tests/core_tests.py b/tests/core_tests.py
index d9a4e95855396..3b66dc3209ba7 100644
--- a/tests/core_tests.py
+++ b/tests/core_tests.py
@@ -12,6 +12,7 @@
from mock import Mock, patch
from flask import escape
+from flask_appbuilder.security.sqla import models as ab_models
import caravel
from caravel import app, db, models, utils, appbuilder
@@ -23,6 +24,7 @@
app.config['CSRF_ENABLED'] = False
app.config['SECRET_KEY'] = 'thisismyscretkey'
app.config['WTF_CSRF_ENABLED'] = False
+app.config['PUBLIC_ROLE_LIKE_GAMMA'] = True
BASE_DIR = app.config.get("BASE_DIR")
cli = imp.load_source('cli', BASE_DIR + "/bin/caravel")
@@ -32,31 +34,58 @@ class CaravelTestCase(unittest.TestCase):
def __init__(self, *args, **kwargs):
super(CaravelTestCase, self).__init__(*args, **kwargs)
self.client = app.test_client()
- role_admin = appbuilder.sm.find_role('Admin')
- user = appbuilder.sm.find_user('admin')
- if not user:
+
+ utils.init(caravel)
+ admin = appbuilder.sm.find_user('admin')
+ if not admin:
appbuilder.sm.add_user(
'admin', 'admin',' user', 'admin@fab.org',
- role_admin, 'general')
+ appbuilder.sm.find_role('Admin'),
+ password='general')
- def login(self):
- self.client.post(
+ gamma = appbuilder.sm.find_user('gamma')
+ if not gamma:
+ appbuilder.sm.add_user(
+ 'gamma', 'gamma', 'user', 'gamma@fab.org',
+ appbuilder.sm.find_role('Gamma'),
+ password='general')
+ utils.init(caravel)
+
+ def login_admin(self):
+ resp = self.client.post(
'/login/',
data=dict(username='admin', password='general'),
follow_redirects=True)
+ assert 'Welcome' in resp.data.decode('utf-8')
+
+ def login_gamma(self):
+ resp = self.client.post(
+ '/login/',
+ data=dict(username='gamma', password='general'),
+ follow_redirects=True)
+ assert 'Welcome' in resp.data.decode('utf-8')
+
+ def setup_public_access_for_dashboard(self, dashboard_name):
+ public_role = appbuilder.sm.find_role('Public')
+ perms = db.session.query(ab_models.PermissionView).all()
+ for perm in perms:
+ if (perm.permission.name == 'datasource_access' and
+ perm.view_menu and dashboard_name in perm.view_menu.name):
+ appbuilder.sm.add_permission_role(public_role, perm)
class CoreTests(CaravelTestCase):
def __init__(self, *args, **kwargs):
+ # Load examples first, so that we setup proper permission-view relations
+ # for all example data sources.
+ self.load_examples()
super(CoreTests, self).__init__(*args, **kwargs)
self.table_ids = {tbl.table_name: tbl.id for tbl in (
db.session
.query(models.SqlaTable)
.all()
)}
- utils.init(caravel)
- self.load_examples()
def setUp(self):
pass
@@ -65,12 +94,16 @@ def tearDown(self):
pass
def load_examples(self):
- cli.load_examples(sample=True)
+ cli.load_examples(load_test_data=True)
def test_save_slice(self):
- self.login()
+ self.login_admin()
+
+ slice_id = (
+ db.session.query(models.Slice.id)
+ .filter_by(slice_name="Energy Sankey")
+ .scalar())
- slice_id = db.session.query(models.Slice.id).filter_by(slice_name="Energy Sankey").scalar()
copy_name = "Test Sankey Save"
tbl_id = self.table_ids.get('energy_usage')
url = "/caravel/explore/table/{}/?viz_type=sankey&groupby=source&groupby=target&metric=sum__value&row_limit=5000&where=&having=&flt_col_0=source&flt_op_0=in&flt_eq_0=&slice_id={}&slice_name={}&collapsed_fieldsets=&action={}&datasource_name=energy_usage&datasource_id=1&datasource_type=table&previous_viz_type=sankey"
@@ -87,19 +120,21 @@ def test_save_slice(self):
def test_slices(self):
# Testing by running all the examples
- self.login()
+ self.login_admin()
Slc = models.Slice
urls = []
for slc in db.session.query(Slc).all():
urls += [
- slc.slice_url,
- slc.viz.json_endpoint,
+ (slc.slice_name, slc.slice_url),
+ (slc.slice_name, slc.viz.json_endpoint),
+ (slc.slice_name, slc.viz.csv_endpoint),
]
- for url in urls:
+ for name, url in urls:
+ print("Slice: " + name)
self.client.get(url)
def test_dashboard(self):
- self.login()
+ self.login_admin()
urls = {}
for dash in db.session.query(models.Dashboard).all():
urls[dash.dashboard_title] = dash.url
@@ -118,19 +153,74 @@ def test_misc(self):
assert self.client.get('/ping').data.decode('utf-8') == "OK"
def test_shortner(self):
- self.login()
+ self.login_admin()
data = "//caravel/explore/table/1/?viz_type=sankey&groupby=source&groupby=target&metric=sum__value&row_limit=5000&where=&having=&flt_col_0=source&flt_op_0=in&flt_eq_0=&slice_id=78&slice_name=Energy+Sankey&collapsed_fieldsets=&action=&datasource_name=energy_usage&datasource_id=1&datasource_type=table&previous_viz_type=sankey"
resp = self.client.post('/r/shortner/', data=data)
assert '/r/' in resp.data.decode('utf-8')
def test_save_dash(self):
- self.login()
+ self.login_admin()
dash = db.session.query(models.Dashboard).filter_by(slug="births").first()
data = """{"positions":[{"slice_id":"131","col":8,"row":8,"size_x":2,"size_y":4},{"slice_id":"132","col":10,"row":8,"size_x":2,"size_y":4},{"slice_id":"133","col":1,"row":1,"size_x":2,"size_y":2},{"slice_id":"134","col":3,"row":1,"size_x":2,"size_y":2},{"slice_id":"135","col":5,"row":4,"size_x":3,"size_y":3},{"slice_id":"136","col":1,"row":7,"size_x":7,"size_y":4},{"slice_id":"137","col":9,"row":1,"size_x":3,"size_y":3},{"slice_id":"138","col":5,"row":1,"size_x":4,"size_y":3},{"slice_id":"139","col":1,"row":3,"size_x":4,"size_y":4},{"slice_id":"140","col":8,"row":4,"size_x":4,"size_y":4}],"css":"None","expanded_slices":{}}"""
url = '/caravel/save_dash/{}/'.format(dash.id)
resp = self.client.post(url, data=dict(data=data))
assert "SUCCESS" in resp.data.decode('utf-8')
+ def test_gamma(self):
+ self.login_gamma()
+ resp = self.client.get('/slicemodelview/list/')
+ print(resp.data.decode('utf-8'))
+ assert "List Slice" in resp.data.decode('utf-8')
+
+ resp = self.client.get('/dashboardmodelview/list/')
+ assert "List Dashboard" in resp.data.decode('utf-8')
+
+ def test_public_user_dashboard_access(self):
+ # Try access before adding appropriate permissions.
+ resp = self.client.get('/slicemodelview/list/')
+ data = resp.data.decode('utf-8')
+ assert 'birth_names' not in data
+
+ resp = self.client.get('/dashboardmodelview/list/')
+ data = resp.data.decode('utf-8')
+ assert '' not in data
+
+ resp = self.client.get('/caravel/explore/table/3/', follow_redirects=True)
+ data = resp.data.decode('utf-8')
+ assert "You don't seem to have access to this datasource" in data
+
+ self.setup_public_access_for_dashboard('birth_names')
+
+ # Try access after adding appropriate permissions.
+ resp = self.client.get('/slicemodelview/list/')
+ data = resp.data.decode('utf-8')
+ assert 'birth_names' in data
+
+ resp = self.client.get('/dashboardmodelview/list/')
+ data = resp.data.decode('utf-8')
+ assert '' in data
+
+ resp = self.client.get('/caravel/dashboard/births/')
+ data = resp.data.decode('utf-8')
+ assert '[dashboard] Births' in data
+
+ resp = self.client.get('/caravel/explore/table/3/')
+ data = resp.data.decode('utf-8')
+ assert '[explore] birth_names' in data
+
+ # Confirm that public doesn't have access to other datasets.
+ resp = self.client.get('/slicemodelview/list/')
+ data = resp.data.decode('utf-8')
+ assert 'wb_health_population' not in data
+
+ resp = self.client.get('/dashboardmodelview/list/')
+ data = resp.data.decode('utf-8')
+ assert '' not in data
+
+ resp = self.client.get('/caravel/explore/table/2/', follow_redirects=True)
+ data = resp.data.decode('utf-8')
+ assert "You don't seem to have access to this datasource" in data
+
SEGMENT_METADATA = [{
"id": "some_id",
@@ -188,7 +278,7 @@ def __init__(self, *args, **kwargs):
@patch('caravel.models.PyDruid')
def test_client(self, PyDruid):
- self.login()
+ self.login_admin()
instance = PyDruid.return_value
instance.time_boundary.return_value = [
{'result': {'maxTime': '2016-01-01'}}]
@@ -229,7 +319,7 @@ def test_client(self, PyDruid):
df = pd.DataFrame(nres)
instance.export_pandas.return_value = df
instance.query_dict = {}
- resp = self.client.get('/caravel/explore/druid/1/?viz_type=table&granularity=one+day&druid_time_origin=&since=7+days+ago&until=now&row_limit=5000&include_search=false&metrics=count&flt_col_0=dim1&flt_op_0=in&flt_eq_0=&slice_id=&slice_name=&collapsed_fieldsets=&action=&datasource_name=test_datasource&datasource_id=1&datasource_type=druid&previous_viz_type=table&json=true&force=true')
+ resp = self.client.get('/caravel/explore/druid/1/?viz_type=table&granularity=one+day&druid_time_origin=&since=7+days+ago&until=now&row_limit=5000&include_search=false&metrics=count&groupby=name&flt_col_0=dim1&flt_op_0=in&flt_eq_0=&slice_id=&slice_name=&collapsed_fieldsets=&action=&datasource_name=test_datasource&datasource_id=1&datasource_type=druid&previous_viz_type=table&json=true&force=true')
print('-'*300)
print(resp.data.decode('utf-8'))
assert "Canada" in resp.data.decode('utf-8')