allow_facet, allow_download, suggest_facets boolean --config

Refs #284
This commit is contained in:
Simon Willison 2018-05-24 18:12:27 -07:00
commit 50920cfe3d
No known key found for this signature in database
GPG key ID: 17E2DEA2588B7F52
9 changed files with 177 additions and 55 deletions

View file

@ -71,6 +71,15 @@ CONFIG_OPTIONS = (
ConfigOption("facet_suggest_time_limit_ms", 50, """ ConfigOption("facet_suggest_time_limit_ms", 50, """
Time limit for calculating a suggested facet Time limit for calculating a suggested facet
""".strip()), """.strip()),
ConfigOption("allow_facet", True, """
Allow users to specify columns to facet using ?_facet= parameter
""".strip()),
ConfigOption("allow_download", True, """
Allow users to download the original SQLite database files
""".strip()),
ConfigOption("suggest_facets", True, """
Calculate and display suggested facets
""".strip()),
) )
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
option.name: option.default option.name: option.default

View file

@ -28,21 +28,35 @@ class StaticMount(click.ParamType):
class Config(click.ParamType): class Config(click.ParamType):
name = "config" name = "config"
def convert(self, value, param, ctx): def convert(self, config, param, ctx):
ok = True if ":" not in config:
if ":" not in value:
ok = False
else:
name, intvalue = value.split(":")
ok = intvalue.isdigit()
if not ok:
self.fail( self.fail(
'"{}" should be of format name:integer'.format(value), '"{}" should be name:value'.format(config), param, ctx
param, ctx
) )
return
name, value = config.split(":")
if name not in DEFAULT_CONFIG: if name not in DEFAULT_CONFIG:
self.fail("{} is not a valid limit".format(name), param, ctx) self.fail("{} is not a valid option".format(name), param, ctx)
return name, int(intvalue) return
# Type checking
default = DEFAULT_CONFIG[name]
if isinstance(default, bool):
if value not in ('on', 'off', 'true', 'false', '1', '0'):
self.fail(
'"{}" should be on/off/true/false'.format(name), param, ctx
)
return
return name, value in ('on', 'true', '1')
elif isinstance(default, int):
if not value.isdigit():
self.fail(
'"{}" should be an integer'.format(name), param, ctx
)
return
return name, int(value)
else:
# Should never happen:
self.fail('Invalid option')
@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True) @click.group(cls=DefaultGroup, default="serve", default_if_no_args=True)

View file

@ -54,7 +54,9 @@
</ul> </ul>
{% endif %} {% endif %}
<p>Download SQLite DB: <a href="/{{ database }}-{{ database_hash }}.db">{{ database }}.db</a></p> {% if config.allow_download %}
<p>Download SQLite DB: <a href="/{{ database }}-{{ database_hash }}.db">{{ database }}.db</a></p>
{% endif %}
{% include "_codemirror_foot.html" %} {% include "_codemirror_foot.html" %}

View file

@ -4,7 +4,7 @@ from sanic import response
from datasette.utils import to_css_class, validate_sql_select from datasette.utils import to_css_class, validate_sql_select
from .base import BaseView from .base import BaseView, DatasetteError
class DatabaseView(BaseView): class DatabaseView(BaseView):
@ -29,6 +29,7 @@ class DatabaseView(BaseView):
{"name": query_name, "sql": query_sql} {"name": query_name, "sql": query_sql}
for query_name, query_sql in (metadata.get("queries") or {}).items() for query_name, query_sql in (metadata.get("queries") or {}).items()
], ],
"config": self.ds.config,
}, { }, {
"database_hash": hash, "database_hash": hash,
"show_hidden": request.args.get("_show_hidden"), "show_hidden": request.args.get("_show_hidden"),
@ -42,6 +43,8 @@ class DatabaseView(BaseView):
class DatabaseDownload(BaseView): class DatabaseDownload(BaseView):
async def view_get(self, request, name, hash, **kwargs): async def view_get(self, request, name, hash, **kwargs):
if not self.ds.config["allow_download"]:
raise DatasetteError("Database download is forbidden", status=403)
filepath = self.ds.inspect()[name]["file"] filepath = self.ds.inspect()[name]["file"]
return await response.file_stream( return await response.file_stream(
filepath, filepath,

View file

@ -542,6 +542,8 @@ class TableView(RowTableShared):
facet_size = self.ds.config["default_facet_size"] facet_size = self.ds.config["default_facet_size"]
metadata_facets = table_metadata.get("facets", []) metadata_facets = table_metadata.get("facets", [])
facets = metadata_facets[:] facets = metadata_facets[:]
if request.args.get("_facet") and not self.ds.config["allow_facet"]:
raise DatasetteError("_facet= is not allowed", status=400)
try: try:
facets.extend(request.args["_facet"]) facets.extend(request.args["_facet"])
except KeyError: except KeyError:
@ -650,41 +652,44 @@ class TableView(RowTableShared):
# Detect suggested facets # Detect suggested facets
suggested_facets = [] suggested_facets = []
for facet_column in columns: if self.ds.config["suggest_facets"] and self.ds.config["allow_facet"]:
if facet_column in facets: for facet_column in columns:
continue if facet_column in facets:
suggested_facet_sql = ''' continue
select distinct {column} {from_sql} if not self.ds.config["suggest_facets"]:
{and_or_where} {column} is not null continue
limit {limit} suggested_facet_sql = '''
'''.format( select distinct {column} {from_sql}
column=escape_sqlite(facet_column), {and_or_where} {column} is not null
from_sql=from_sql, limit {limit}
and_or_where='and' if from_sql_where_clauses else 'where', '''.format(
limit=facet_size+1 column=escape_sqlite(facet_column),
) from_sql=from_sql,
distinct_values = None and_or_where='and' if from_sql_where_clauses else 'where',
try: limit=facet_size+1
distinct_values = await self.ds.execute(
name, suggested_facet_sql, from_sql_params,
truncate=False,
custom_time_limit=self.ds.config["facet_suggest_time_limit_ms"],
) )
num_distinct_values = len(distinct_values) distinct_values = None
if ( try:
num_distinct_values and distinct_values = await self.ds.execute(
num_distinct_values > 1 and name, suggested_facet_sql, from_sql_params,
num_distinct_values <= facet_size and truncate=False,
num_distinct_values < filtered_table_rows_count custom_time_limit=self.ds.config["facet_suggest_time_limit_ms"],
): )
suggested_facets.append({ num_distinct_values = len(distinct_values)
'name': facet_column, if (
'toggle_url': path_with_added_args( num_distinct_values and
request, {'_facet': facet_column} num_distinct_values > 1 and
), num_distinct_values <= facet_size and
}) num_distinct_values < filtered_table_rows_count
except InterruptedError: ):
pass suggested_facets.append({
'name': facet_column,
'toggle_url': path_with_added_args(
request, {'_facet': facet_column}
),
})
except InterruptedError:
pass
# human_description_en combines filters AND search, if provided # human_description_en combines filters AND search, if provided
human_description_en = filters.human_description_en(extra=search_descriptions) human_description_en = filters.human_description_en(extra=search_descriptions)

View file

@ -14,7 +14,6 @@ The default number of rows returned by the table page. You can over-ride this on
datasette mydatabase.db --config default_page_size:50 datasette mydatabase.db --config default_page_size:50
sql_time_limit_ms sql_time_limit_ms
----------------- -----------------
@ -39,6 +38,17 @@ You can increase or decrease this limit like so::
datasette mydatabase.db --config max_returned_rows:2000 datasette mydatabase.db --config max_returned_rows:2000
allow_facet
-----------
Allow users to specify columns they would like to facet on using the ``?_facet=COLNAME`` URL parameter to the table view.
This is enabled by default. If disabled, facets will still be displayed if they have been specifically enabled in ``metadata.json`` configuration for the table.
Here's how to disable this feature::
datasette mydatabase.db --config allow_facet:off
default_facet_size default_facet_size
------------------ ------------------
@ -61,3 +71,17 @@ When Datasette calculates suggested facets it needs to run a SQL query for every
You can increase this time limit like so:: You can increase this time limit like so::
datasette mydatabase.db --config facet_suggest_time_limit_ms:500 datasette mydatabase.db --config facet_suggest_time_limit_ms:500
suggest_facets
--------------
Should Datasette calculate suggested facets? On by default, turn this off like so::
datasette mydatabase.db --config suggest_facets:off
allow_download
--------------
Should users be able to download the original SQLite database using a link on the database index page? This is turned on by default - to disable database downloads, use the following::
datasette mydatabase.db --config allow_download:off

View file

@ -9,7 +9,7 @@ import tempfile
import time import time
def app_client(sql_time_limit_ms=None, max_returned_rows=None): def app_client(sql_time_limit_ms=None, max_returned_rows=None, config=None):
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
filepath = os.path.join(tmpdir, 'test_tables.db') filepath = os.path.join(tmpdir, 'test_tables.db')
conn = sqlite3.connect(filepath) conn = sqlite3.connect(filepath)
@ -18,15 +18,17 @@ def app_client(sql_time_limit_ms=None, max_returned_rows=None):
plugins_dir = os.path.join(tmpdir, 'plugins') plugins_dir = os.path.join(tmpdir, 'plugins')
os.mkdir(plugins_dir) os.mkdir(plugins_dir)
open(os.path.join(plugins_dir, 'my_plugin.py'), 'w').write(PLUGIN) open(os.path.join(plugins_dir, 'my_plugin.py'), 'w').write(PLUGIN)
config = config or {}
config.update({
'default_page_size': 50,
'max_returned_rows': max_returned_rows or 100,
'sql_time_limit_ms': sql_time_limit_ms or 200,
})
ds = Datasette( ds = Datasette(
[filepath], [filepath],
metadata=METADATA, metadata=METADATA,
plugins_dir=plugins_dir, plugins_dir=plugins_dir,
config={ config=config,
'default_page_size': 50,
'max_returned_rows': max_returned_rows or 100,
'sql_time_limit_ms': sql_time_limit_ms or 200,
}
) )
ds.sqlite_functions.append( ds.sqlite_functions.append(
('sleep', 1, lambda n: time.sleep(float(n))), ('sleep', 1, lambda n: time.sleep(float(n))),

View file

@ -914,6 +914,9 @@ def test_config_json(app_client):
"facet_time_limit_ms": 200, "facet_time_limit_ms": 200,
"max_returned_rows": 100, "max_returned_rows": 100,
"sql_time_limit_ms": 200, "sql_time_limit_ms": 200,
"allow_download": True,
"allow_facet": True,
"suggest_facets": True
} == response.json } == response.json
@ -1080,3 +1083,36 @@ def test_facets(app_client, path, expected_facet_results):
for facet_value in facet_info["results"]: for facet_value in facet_info["results"]:
facet_value['toggle_url'] = facet_value['toggle_url'].split('?')[1] facet_value['toggle_url'] = facet_value['toggle_url'].split('?')[1]
assert expected_facet_results == facet_results assert expected_facet_results == facet_results
def test_suggested_facets(app_client):
assert len(app_client.get(
"/test_tables/facetable.json",
gather_request=False
).json["suggested_facets"]) > 0
def test_allow_facet_off():
for client in app_client(config={
'allow_facet': False,
}):
assert 400 == client.get(
"/test_tables/facetable.json?_facet=planet_int",
gather_request=False
).status
# Should not suggest any facets either:
assert [] == client.get(
"/test_tables/facetable.json",
gather_request=False
).json["suggested_facets"]
def test_suggest_facets_off():
for client in app_client(config={
'suggest_facets': False,
}):
# Now suggested_facets should be []
assert [] == client.get(
"/test_tables/facetable.json",
gather_request=False
).json["suggested_facets"]

View file

@ -468,6 +468,33 @@ def test_table_metadata(app_client):
assert_footer_links(soup) assert_footer_links(soup)
def test_allow_download_on(app_client):
response = app_client.get(
"/test_tables",
gather_request=False
)
soup = Soup(response.body, 'html.parser')
assert len(soup.findAll('a', {'href': re.compile('\.db$')}))
def test_allow_download_off():
for client in app_client(config={
'allow_download': False,
}):
response = client.get(
"/test_tables",
gather_request=False
)
soup = Soup(response.body, 'html.parser')
assert not len(soup.findAll('a', {'href': re.compile('\.db$')}))
# Accessing URL directly should 403
response = client.get(
"/test_tables.db",
gather_request=False
)
assert 403 == response.status
def assert_querystring_equal(expected, actual): def assert_querystring_equal(expected, actual):
assert sorted(expected.split('&')) == sorted(actual.split('&')) assert sorted(expected.split('&')) == sorted(actual.split('&'))