kopia lustrzana https://github.com/simonw/datasette
rodzic
81df47e8d9
commit
50920cfe3d
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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" %}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))),
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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('&'))
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue