Commits (2)
from pprint import pprint from pprint import pprint
import re import re
import sqlite3
import sys import sys
from typing import Coroutine from typing import Coroutine, Mapping
import urllib import urllib.parse
from datasette import hookimpl from datasette.database import Database
from datasette.plugins import pm from datasette.plugins import pm
from datasette.hookspecs import hookspec from datasette.hookspecs import hookspec, hookimpl
from datasette.utils.asgi import Forbidden, NotFound, Response from datasette.utils.asgi import Forbidden, NotFound, Response
from jinja2 import Markup # type: ignore from jinja2.utils import Markup
import jinja2 # type: ignore
sql_opt_pattern = re.compile(r"(?P<opt>\[\[[^\]]*\]\])") sql_opt_pattern = re.compile(r"(?P<opt>\[\[[^\]]*\]\])")
...@@ -16,21 +18,32 @@ sql_var_pattern = re.compile(r"\:(?P<var>[a-zA-Z0-9_]+)") ...@@ -16,21 +18,32 @@ sql_var_pattern = re.compile(r"\:(?P<var>[a-zA-Z0-9_]+)")
@hookspec(firstresult=True) @hookspec(firstresult=True)
async def render_custom_dashboard_chart(chart_display): async def render_custom_dashboard_chart(chart_display):
""" Render a custom dashboard chart display. """ """Render a custom dashboard chart display."""
pm.add_hookspecs(sys.modules[__name__]) pm.add_hookspecs(sys.modules[__name__])
@hookimpl @hookimpl
def extra_template_vars(datasette, database): def extra_template_vars(datasette, database):
async def render_custom_dashboard_chart(chart_display): async def render_custom_dashboard_chart(chart_display):
result = pm.hook.render_custom_dashboard_chart(chart_display=chart_display) # type: ignore result = pm.hook.render_custom_dashboard_chart(chart_display=chart_display) # type: ignore
if not result: if not result:
result = chart_display result = chart_display
if isinstance(result, Coroutine): if isinstance(result, Coroutine):
result = await result result = await result
return Markup(result) return Markup(result)
return {"render_custom_dashboard_chart": render_custom_dashboard_chart } def alter_query_url(query: str, overrides={}):
url = urllib.parse.parse_qs(query)
url.update(overrides)
return urllib.parse.urlencode(url)
return {
"render_custom_dashboard_chart": render_custom_dashboard_chart,
"alter_query_url": alter_query_url,
}
async def check_permission_instance(request, datasette): async def check_permission_instance(request, datasette):
if ( if (
...@@ -61,11 +74,26 @@ def get_dashboard_filters_keys(request, dashboard): ...@@ -61,11 +74,26 @@ def get_dashboard_filters_keys(request, dashboard):
async def populate_dashboard_filter_queries(database, dashboard): async def populate_dashboard_filter_queries(database, dashboard):
"""
Populate filters with values returned by a query.
"""
filters = dashboard.get("filters") or {} filters = dashboard.get("filters") or {}
for key,filter in filters.items(): for key, filter in filters.items():
if 'query' in filter: if "query" in filter:
res = await database.execute(filter["query"]) res = await database.execute(filter["query"])
filter['options'] = {row['key']:row['label'] for row in res} filter["options"] = {row["key"]: row["label"] for row in res}
async def populate_chart_canned_queries(datasette, database, request, chart):
"""
Reference 'canned queries' by specifying a canned query name in the
chart definition metadata.
"""
queries = await datasette.get_canned_queries(database.name, request.actor)
if chart["query"] in queries.keys():
query = queries.get(chart["query"], None)
if query:
chart["query"] = query["sql"]
def get_dashboard_filters(request, opts_keys): def get_dashboard_filters(request, opts_keys):
...@@ -119,6 +147,7 @@ async def dashboard_view(request, datasette): ...@@ -119,6 +147,7 @@ async def dashboard_view(request, datasette):
raise NotFound(f"Dashboard not found: {slug}") raise NotFound(f"Dashboard not found: {slug}")
dbs = set([chart["db"] for chart in dashboard["charts"].values() if "db" in chart]) dbs = set([chart["db"] for chart in dashboard["charts"].values() if "db" in chart])
database: Database = None # type: ignore
for db in dbs: for db in dbs:
try: try:
database = datasette.get_database(db) database = datasette.get_database(db)
...@@ -131,7 +160,17 @@ async def dashboard_view(request, datasette): ...@@ -131,7 +160,17 @@ async def dashboard_view(request, datasette):
query_string = generate_dashboard_filters_qs(request, options_keys) query_string = generate_dashboard_filters_qs(request, options_keys)
for chart in dashboard["charts"].values(): for chart in dashboard["charts"].values():
fill_chart_query_options(chart, options_keys) await render_chart(
datasette,
dashboard,
database,
request,
options_keys,
query_string,
slug,
query_parameters,
chart,
)
return Response.html( return Response.html(
await datasette.render_template( await datasette.render_template(
...@@ -146,6 +185,39 @@ async def dashboard_view(request, datasette): ...@@ -146,6 +185,39 @@ async def dashboard_view(request, datasette):
) )
async def render_chart(
datasette,
dashboard,
database,
request,
options_keys,
query_string,
slug,
query_parameters,
chart,
):
await populate_chart_canned_queries(datasette, database, request, chart)
fill_chart_query_options(chart, options_keys)
if chart["library"] == "jinja":
try:
data = await database.execute(chart["query"], query_parameters)
data = [{col: row[col] for col in data.columns} for row in data.rows]
except sqlite3.ProgrammingError:
return
chart["html"] = Markup(
await datasette.render_template(
chart["template"],
{
"slug": slug,
"query_parameters": query_parameters,
"query_string": query_string,
"dashboard": dashboard,
"data": data,
},
)
)
async def dashboard_chart(request, datasette): async def dashboard_chart(request, datasette):
await check_permission_instance(request, datasette) await check_permission_instance(request, datasette)
...@@ -163,14 +235,28 @@ async def dashboard_chart(request, datasette): ...@@ -163,14 +235,28 @@ async def dashboard_chart(request, datasette):
except KeyError: except KeyError:
raise NotFound(f"Chart does not exist: {chart_slug}") raise NotFound(f"Chart does not exist: {chart_slug}")
database: Database = None # type: ignore
db = chart.get("db") db = chart.get("db")
if db: if db:
database = datasette.get_database(db) database = datasette.get_database(db)
await check_permission_execute_sql(request, datasette, database) await check_permission_execute_sql(request, datasette, database)
await populate_dashboard_filter_queries(database, dashboard)
options_keys = get_dashboard_filters_keys(request, dashboard) options_keys = get_dashboard_filters_keys(request, dashboard)
query_parameters = get_dashboard_filters(request, options_keys)
query_string = generate_dashboard_filters_qs(request, options_keys) query_string = generate_dashboard_filters_qs(request, options_keys)
fill_chart_query_options(chart, options_keys)
await render_chart(
datasette,
dashboard,
database,
request,
options_keys,
query_string,
slug,
query_parameters,
chart,
)
return Response.html( return Response.html(
await datasette.render_template( await datasette.render_template(
......
function renderVegaChart(el, chart, query_string, height_style = undefined) { function renderVegaChart(el, chart, query_string, height_style = undefined) {
const query = encodeURIComponent(chart.query)
var data = []
function datasource(chart, dataspec, query_string) {
var type = dataspec['type']
var query = encodeURIComponent( dataspec['query']);
if (type == 'sql') {
return {
name: dataspec['name'],
url: `/${chart.db}.json?_shape=objects&sql=${query}&${query_string}`,
format: {'type': 'json', 'property': 'rows'}
}
} else if (type == 'query') {
return {
name: dataspec['name'],
url: `/${chart.db}/${query}.json?_shape=objects&${query_string}`,
format: {'type': 'json', "proprty": "rows"}
}
}
}
const spec = { const spec = {
$schema: 'https://vega.github.io/schema/vega-lite/v5.json', $schema: 'https://vega.github.io/schema/vega-lite/v5.json',
description: chart.title, description: chart.title,
...@@ -15,15 +36,30 @@ function renderVegaChart(el, chart, query_string, height_style = undefined) { ...@@ -15,15 +36,30 @@ function renderVegaChart(el, chart, query_string, height_style = undefined) {
point: true point: true
} }
}, },
data: { datasets: {
url: `/${chart.db}.csv?sql=${query}&${query_string}`,
format: {'type': 'csv'}
}, },
data: datasource(chart, {name: 'data', type:'sql', query:chart.query}, query_string),
...chart.display ...chart.display
}; };
vegaEmbed(el, spec).then(function(result) { vegaEmbed(el, spec).then(function(result) {
result.view.logLevel(vega.Debug);
console.log('data:',result.view.data('data'));
console.log('Vega-embed view:',result.view); console.log('Vega-embed view:',result.view);
console.log('data source:',result.view._runtime.data.data.input.value);
console.log('data transformed:',result.view._runtime.data.data);
for (var i in chart.data) {
var dataspec = chart.data[i];
var ds = datasource(chart, dataspec, query_string);
var loaded = result.view._loader.load(ds.url);
loaded.then(function(val){
console.log(val);
spec.datasets[dataspec['name']] = val;
});
}
console.log(spec);
result.view.run();
}); });
} }
......
...@@ -40,21 +40,23 @@ ...@@ -40,21 +40,23 @@
<div id="chart" class="chart-container"> <div id="chart" class="chart-container">
{% if chart.library == 'markdown' %} {% if chart.library == 'markdown' %}
{{ render_markdown(chart.display) }} {{ render_markdown(chart.display) }}
{% elif chart.library == 'jinja' %}
{{ chart.html }}
{% elif chart.library == 'custom' %} {% elif chart.library == 'custom' %}
{{ render_custom_dashboard_chart(chart.display) }} {{ render_custom_dashboard_chart(chart.display) }}
{% endif %} {% endif %}
</div> </div>
</div> </div>
<script id="data" type="application/json">{{ chart|tojson }}</script>
<script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script> <script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script> <script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.17.0"></script> <script src="https://cdn.jsdelivr.net/npm/vega-embed@6.17.0"></script>
<script src="{{ urls.static_plugins('datasette_dashboards', 'dashboards.js') }}"></script> <script src="{{ urls.static_plugins('datasette_dashboards', 'dashboards.js') }}"></script>
<script type="text/javascript"> <script type="text/javascript">
{% if chart.library == 'vega' %} {% if chart.library == 'vega' %}
renderVegaChart('#chart', JSON.parse('{{ chart|tojson }}'), '{{ query_string|safe }}', 'container') renderVegaChart('#chart', JSON.parse(document.getElementById("data").text), '{{ query_string|safe }}', 'container')
{% elif chart.library == 'metric' %} {% elif chart.library == 'metric' %}
renderMetricChart('#chart', JSON.parse('{{ chart|tojson }}'), '{{ query_string|safe }}') renderMetricChart('#chart', JSON.parse(document.getElementById("data").text), '{{ query_string|safe }}')
{% endif %} {% endif %}
</script> </script>
{% endblock %} {% endblock %}
...@@ -73,7 +73,7 @@ ...@@ -73,7 +73,7 @@
<div class="dashboard-grid"> <div class="dashboard-grid">
{% for chart_slug, chart in dashboard.charts.items() %} {% for chart_slug, chart in dashboard.charts.items() %}
<div id="card-{{ chart_slug }}" class="dashboard-card"> <div id="card-{{ chart_slug }}" class="dashboard-card">
{% if chart.library != 'markdown' %} {% if chart.title and chart.library != 'markdown' %}
<div class="dashboard-card-title"> <div class="dashboard-card-title">
<p><a href="{{ urls.path('-/dashboards') }}/{{ slug }}/{{ chart_slug }}{% if query_string|length > 0 %}?{{ query_string|safe }}{% endif %}">{{ chart.title }}</a></p> <p><a href="{{ urls.path('-/dashboards') }}/{{ slug }}/{{ chart_slug }}{% if query_string|length > 0 %}?{{ query_string|safe }}{% endif %}">{{ chart.title }}</a></p>
</div> </div>
...@@ -82,6 +82,8 @@ ...@@ -82,6 +82,8 @@
<div id="chart-{{ chart_slug }}" class="dashboard-card-chart"> <div id="chart-{{ chart_slug }}" class="dashboard-card-chart">
{% if chart.library == 'markdown' %} {% if chart.library == 'markdown' %}
{{ render_markdown(chart.display) }} {{ render_markdown(chart.display) }}
{% elif chart.library == 'jinja' %}
{{ chart.html }}
{% elif chart.library == 'custom' %} {% elif chart.library == 'custom' %}
{{ render_custom_dashboard_chart(chart.display) }} {{ render_custom_dashboard_chart(chart.display) }}
{% endif %} {% endif %}
...@@ -89,7 +91,7 @@ ...@@ -89,7 +91,7 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<script id="data" type="application/json">{{ dashboard.charts|tojson }}</script>
<script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script> <script src="https://cdn.jsdelivr.net/npm/vega@5.20.2"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script> <script src="https://cdn.jsdelivr.net/npm/vega-lite@5.1.0"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6.17.0"></script> <script src="https://cdn.jsdelivr.net/npm/vega-embed@6.17.0"></script>
...@@ -97,9 +99,9 @@ ...@@ -97,9 +99,9 @@
<script type="text/javascript"> <script type="text/javascript">
{% for chart_slug, chart in dashboard.charts.items() %} {% for chart_slug, chart in dashboard.charts.items() %}
{% if chart.library == 'vega' %} {% if chart.library == 'vega' %}
renderVegaChart('#chart-{{ chart_slug }}', JSON.parse('{{ chart|tojson }}'), '{{ query_string|safe }}') renderVegaChart('#chart-{{ chart_slug }}', JSON.parse(document.getElementById("data").text)['{{chart_slug}}'], '{{ query_string|safe }}')
{% elif chart.library == 'metric' %} {% elif chart.library == 'metric' %}
renderMetricChart('#chart-{{ chart_slug }}', JSON.parse('{{ chart|tojson }}'), '{{ query_string|safe }}') renderMetricChart('#chart-{{ chart_slug }}', JSON.parse(document.getElementById("data").text)['{{chart_slug}}'], '{{ query_string|safe }}')
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</script> </script>
......