allow_facet, allow_download, suggest_facets boolean --config

Refs #284
pull/377/head
Simon Willison 2018-05-24 18:12:27 -07:00
rodzic 81df47e8d9
commit 50920cfe3d
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 17E2DEA2588B7F52
9 zmienionych plików z 177 dodań i 55 usunięć

Wyświetl plik

@ -71,6 +71,15 @@ CONFIG_OPTIONS = (
ConfigOption("facet_suggest_time_limit_ms", 50, """
Time limit for calculating a suggested facet
""".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 = {
option.name: option.default

Wyświetl plik

@ -28,21 +28,35 @@ class StaticMount(click.ParamType):
class Config(click.ParamType):
name = "config"
def convert(self, value, param, ctx):
ok = True
if ":" not in value:
ok = False
else:
name, intvalue = value.split(":")
ok = intvalue.isdigit()
if not ok:
def convert(self, config, param, ctx):
if ":" not in config:
self.fail(
'"{}" should be of format name:integer'.format(value),
param, ctx
'"{}" should be name:value'.format(config), param, ctx
)
return
name, value = config.split(":")
if name not in DEFAULT_CONFIG:
self.fail("{} is not a valid limit".format(name), param, ctx)
return name, int(intvalue)
self.fail("{} is not a valid option".format(name), param, ctx)
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)

Wyświetl plik

@ -54,7 +54,9 @@
</ul>
{% 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" %}

Wyświetl plik

@ -4,7 +4,7 @@ from sanic import response
from datasette.utils import to_css_class, validate_sql_select
from .base import BaseView
from .base import BaseView, DatasetteError
class DatabaseView(BaseView):
@ -29,6 +29,7 @@ class DatabaseView(BaseView):
{"name": query_name, "sql": query_sql}
for query_name, query_sql in (metadata.get("queries") or {}).items()
],
"config": self.ds.config,
}, {
"database_hash": hash,
"show_hidden": request.args.get("_show_hidden"),
@ -42,6 +43,8 @@ class DatabaseView(BaseView):
class DatabaseDownload(BaseView):
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"]
return await response.file_stream(
filepath,

Wyświetl plik

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

Wyświetl plik

@ -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
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
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
------------------
@ -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::
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

Wyświetl plik

@ -9,7 +9,7 @@ import tempfile
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:
filepath = os.path.join(tmpdir, 'test_tables.db')
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')
os.mkdir(plugins_dir)
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(
[filepath],
metadata=METADATA,
plugins_dir=plugins_dir,
config={
'default_page_size': 50,
'max_returned_rows': max_returned_rows or 100,
'sql_time_limit_ms': sql_time_limit_ms or 200,
}
config=config,
)
ds.sqlite_functions.append(
('sleep', 1, lambda n: time.sleep(float(n))),

Wyświetl plik

@ -914,6 +914,9 @@ def test_config_json(app_client):
"facet_time_limit_ms": 200,
"max_returned_rows": 100,
"sql_time_limit_ms": 200,
"allow_download": True,
"allow_facet": True,
"suggest_facets": True
} == response.json
@ -1080,3 +1083,36 @@ def test_facets(app_client, path, expected_facet_results):
for facet_value in facet_info["results"]:
facet_value['toggle_url'] = facet_value['toggle_url'].split('?')[1]
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"]

Wyświetl plik

@ -468,6 +468,33 @@ def test_table_metadata(app_client):
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):
assert sorted(expected.split('&')) == sorted(actual.split('&'))