Add /-/actions endpoint to list registered actions

This adds a new endpoint at /-/actions that lists all registered actions
in the permission system. The endpoint supports both JSON and HTML output.

Changes:
- Added _actions() method to Datasette class to return action list
- Added route for /-/actions with JsonDataView
- Created actions.html template for nice HTML display
- Added template parameter to JsonDataView for custom templates
- Moved respond_json_or_html from BaseView to JsonDataView
- Added test for the new endpoint

The endpoint requires view-instance permission and provides details about
each action including name, abbreviation, description, resource class,
and parent/child requirements.

Closes #2547

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Simon Willison 2025-10-26 16:10:58 -07:00
commit b3721eaf50
6 changed files with 121 additions and 19 deletions

View file

@ -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(\.(?P<format>json))?$",
)
add_route(
JsonDataView.as_view(
self, "actions.json", self._actions, template="actions.html"
),
r"/-/actions(\.(?P<format>json))?$",
)
add_route(
AuthTokenView.as_view(self),
r"/-/auth-token$",

View file

@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Registered Actions{% endblock %}
{% block content %}
<h1>Registered Actions</h1>
<p style="margin-bottom: 2em;">
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.
</p>
<table class="rows-and-columns">
<thead>
<tr>
<th>Name</th>
<th>Abbr</th>
<th>Description</th>
<th>Resource</th>
<th>Takes Parent</th>
<th>Takes Child</th>
<th>Also Requires</th>
</tr>
</thead>
<tbody>
{% for action in data %}
<tr>
<td><strong>{{ action.name }}</strong></td>
<td>{% if action.abbr %}<code>{{ action.abbr }}</code>{% endif %}</td>
<td>{{ action.description or "" }}</td>
<td><code>{{ action.resource_class }}</code></td>
<td>{% if action.takes_parent %}✓{% endif %}</td>
<td>{% if action.takes_child %}✓{% endif %}</td>
<td>{% if action.also_requires %}<code>{{ action.also_requires }}</code>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View file

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

View file

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

View file

@ -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")

View file

@ -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 "<th>Name</th>" in response.text
assert "view-instance" in response.text
assert "view-database" in response.text