diff --git a/datasette/app.py b/datasette/app.py index 5e4be23b..f141ed45 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -1554,6 +1554,20 @@ class Datasette: def _actor(self, request): return {"actor": request.actor} + def _actions(self): + return [ + { + "name": action.name, + "abbr": action.abbr, + "description": action.description, + "takes_parent": action.takes_parent, + "takes_child": action.takes_child, + "resource_class": action.resource_class.__name__, + "also_requires": action.also_requires, + } + for action in sorted(self.actions.values(), key=lambda a: a.name) + ] + async def table_config(self, database: str, table: str) -> dict: """Return dictionary of configuration for specified table""" return ( @@ -1819,6 +1833,12 @@ class Datasette: ), r"/-/actor(\.(?Pjson))?$", ) + add_route( + JsonDataView.as_view( + self, "actions.json", self._actions, template="actions.html" + ), + r"/-/actions(\.(?Pjson))?$", + ) add_route( AuthTokenView.as_view(self), r"/-/auth-token$", diff --git a/datasette/templates/actions.html b/datasette/templates/actions.html new file mode 100644 index 00000000..b4285d79 --- /dev/null +++ b/datasette/templates/actions.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}Registered Actions{% endblock %} + +{% block content %} +

Registered Actions

+ +

+ This Datasette instance has registered {{ data|length }} action{{ data|length != 1 and "s" or "" }}. + Actions are used by the permission system to control access to different features. +

+ + + + + + + + + + + + + + + {% for action in data %} + + + + + + + + + + {% endfor %} + +
NameAbbrDescriptionResourceTakes ParentTakes ChildAlso Requires
{{ action.name }}{% if action.abbr %}{{ action.abbr }}{% endif %}{{ action.description or "" }}{{ action.resource_class }}{% if action.takes_parent %}✓{% endif %}{% if action.takes_child %}✓{% endif %}{% if action.also_requires %}{{ action.also_requires }}{% endif %}
+ +{% endblock %} diff --git a/datasette/views/base.py b/datasette/views/base.py index ea48a398..5216924f 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -174,24 +174,6 @@ class BaseView: headers=headers, ) - async def respond_json_or_html(self, request, data, filename): - """Return JSON or HTML with pretty JSON depending on format parameter.""" - as_format = request.url_vars.get("format") - if as_format: - headers = {} - if self.ds.cors: - add_cors_headers(headers) - return Response.json(data, headers=headers) - else: - return await self.render( - ["show_json.html"], - request=request, - context={ - "filename": filename, - "data_json": json.dumps(data, indent=4, default=repr), - }, - ) - @classmethod def as_view(cls, *class_args, **class_kwargs): async def view(request, send): diff --git a/datasette/views/special.py b/datasette/views/special.py index b5ad2e8b..3cc83ba0 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -27,6 +27,7 @@ def _resource_path(parent, child): class JsonDataView(BaseView): name = "json_data" + template = "show_json.html" # Can be overridden in subclasses def __init__( self, @@ -35,12 +36,15 @@ class JsonDataView(BaseView): data_callback, needs_request=False, permission="view-instance", + template=None, ): self.ds = datasette self.filename = filename self.data_callback = data_callback self.needs_request = needs_request self.permission = permission + if template is not None: + self.template = template async def get(self, request): if self.permission: @@ -49,7 +53,24 @@ class JsonDataView(BaseView): data = self.data_callback(request) else: data = self.data_callback() - return await self.respond_json_or_html(request, data, self.filename) + + # Return JSON or HTML depending on format parameter + as_format = request.url_vars.get("format") + if as_format: + headers = {} + if self.ds.cors: + add_cors_headers(headers) + return Response.json(data, headers=headers) + else: + return await self.render( + [self.template], + request=request, + context={ + "filename": self.filename, + "data": data, + "data_json": json.dumps(data, indent=4, default=repr), + }, + ) class PatternPortfolioView(View): diff --git a/tests/test_api.py b/tests/test_api.py index 31798668..7c8a8300 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -833,6 +833,35 @@ async def test_versions_json(ds_client): assert data["sqlite"]["extensions"]["json1"] +@pytest.mark.asyncio +async def test_actions_json(ds_client): + response = await ds_client.get("/-/actions.json") + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + # Check structure of first action + action = data[0] + for key in ( + "name", + "abbr", + "description", + "takes_parent", + "takes_child", + "resource_class", + "also_requires", + ): + assert key in action + # Check that some expected actions exist + action_names = {a["name"] for a in data} + for expected_action in ( + "view-instance", + "view-database", + "view-table", + "execute-sql", + ): + assert expected_action in action_names + + @pytest.mark.asyncio async def test_settings_json(ds_client): response = await ds_client.get("/-/settings.json") diff --git a/tests/test_html.py b/tests/test_html.py index e6d3eb93..7fade152 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1176,3 +1176,13 @@ async def test_custom_csrf_error(ds_client): assert response.status_code == 403 assert response.headers["content-type"] == "text/html; charset=utf-8" assert "Error code is FORM_URLENCODED_MISMATCH." in response.text + + +@pytest.mark.asyncio +async def test_actions_page(ds_client): + response = await ds_client.get("/-/actions") + assert response.status_code == 200 + assert "Registered Actions" in response.text + assert "Name" in response.text + assert "view-instance" in response.text + assert "view-database" in response.text