diff --git a/datasette/app.py b/datasette/app.py index 2d352097..093a7383 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -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 diff --git a/datasette/cli.py b/datasette/cli.py index 7dc03195..d996708f 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -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) diff --git a/datasette/templates/database.html b/datasette/templates/database.html index 1d404552..81378ad9 100644 --- a/datasette/templates/database.html +++ b/datasette/templates/database.html @@ -54,7 +54,9 @@ {% endif %} -

Download SQLite DB: {{ database }}.db

+{% if config.allow_download %} +

Download SQLite DB: {{ database }}.db

+{% endif %} {% include "_codemirror_foot.html" %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 3ccd7ef0..db824be4 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -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, diff --git a/datasette/views/table.py b/datasette/views/table.py index 36f79f40..12837acc 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -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) diff --git a/docs/config.rst b/docs/config.rst index 908f1d4b..54ea1ead 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -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 diff --git a/tests/fixtures.py b/tests/fixtures.py index f94be72b..ace1f7e6 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -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))), diff --git a/tests/test_api.py b/tests/test_api.py index ec81d520..2c5b2479 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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"] diff --git a/tests/test_html.py b/tests/test_html.py index 3e94275f..534de60a 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -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('&'))