mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
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:
parent
5c537e0a3e
commit
b3721eaf50
6 changed files with 121 additions and 19 deletions
|
|
@ -1554,6 +1554,20 @@ class Datasette:
|
||||||
def _actor(self, request):
|
def _actor(self, request):
|
||||||
return {"actor": request.actor}
|
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:
|
async def table_config(self, database: str, table: str) -> dict:
|
||||||
"""Return dictionary of configuration for specified table"""
|
"""Return dictionary of configuration for specified table"""
|
||||||
return (
|
return (
|
||||||
|
|
@ -1819,6 +1833,12 @@ class Datasette:
|
||||||
),
|
),
|
||||||
r"/-/actor(\.(?P<format>json))?$",
|
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(
|
add_route(
|
||||||
AuthTokenView.as_view(self),
|
AuthTokenView.as_view(self),
|
||||||
r"/-/auth-token$",
|
r"/-/auth-token$",
|
||||||
|
|
|
||||||
40
datasette/templates/actions.html
Normal file
40
datasette/templates/actions.html
Normal 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 %}
|
||||||
|
|
@ -174,24 +174,6 @@ class BaseView:
|
||||||
headers=headers,
|
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
|
@classmethod
|
||||||
def as_view(cls, *class_args, **class_kwargs):
|
def as_view(cls, *class_args, **class_kwargs):
|
||||||
async def view(request, send):
|
async def view(request, send):
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ def _resource_path(parent, child):
|
||||||
|
|
||||||
class JsonDataView(BaseView):
|
class JsonDataView(BaseView):
|
||||||
name = "json_data"
|
name = "json_data"
|
||||||
|
template = "show_json.html" # Can be overridden in subclasses
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -35,12 +36,15 @@ class JsonDataView(BaseView):
|
||||||
data_callback,
|
data_callback,
|
||||||
needs_request=False,
|
needs_request=False,
|
||||||
permission="view-instance",
|
permission="view-instance",
|
||||||
|
template=None,
|
||||||
):
|
):
|
||||||
self.ds = datasette
|
self.ds = datasette
|
||||||
self.filename = filename
|
self.filename = filename
|
||||||
self.data_callback = data_callback
|
self.data_callback = data_callback
|
||||||
self.needs_request = needs_request
|
self.needs_request = needs_request
|
||||||
self.permission = permission
|
self.permission = permission
|
||||||
|
if template is not None:
|
||||||
|
self.template = template
|
||||||
|
|
||||||
async def get(self, request):
|
async def get(self, request):
|
||||||
if self.permission:
|
if self.permission:
|
||||||
|
|
@ -49,7 +53,24 @@ class JsonDataView(BaseView):
|
||||||
data = self.data_callback(request)
|
data = self.data_callback(request)
|
||||||
else:
|
else:
|
||||||
data = self.data_callback()
|
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):
|
class PatternPortfolioView(View):
|
||||||
|
|
|
||||||
|
|
@ -833,6 +833,35 @@ async def test_versions_json(ds_client):
|
||||||
assert data["sqlite"]["extensions"]["json1"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_settings_json(ds_client):
|
async def test_settings_json(ds_client):
|
||||||
response = await ds_client.get("/-/settings.json")
|
response = await ds_client.get("/-/settings.json")
|
||||||
|
|
|
||||||
|
|
@ -1176,3 +1176,13 @@ async def test_custom_csrf_error(ds_client):
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
assert response.headers["content-type"] == "text/html; charset=utf-8"
|
assert response.headers["content-type"] == "text/html; charset=utf-8"
|
||||||
assert "Error code is FORM_URLENCODED_MISMATCH." in response.text
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue