Add web UI to edit and delete stored queries

Stored query pages now offer Edit and Delete actions in the query
actions menu, gated by the update-query and delete-query permissions.

- New QueryEditView (GET/POST at /<db>/<query>/-/edit) renders a
  pre-filled form for editing a query's title, description, SQL and
  privacy, reusing the create-query analysis UI. Changing the SQL still
  requires execute-sql; metadata-only edits do not.
- QueryDeleteView gains a GET confirmation page and HTML form POST that
  redirects to the query list, while keeping the existing JSON API.
- New default query_actions hook adds the Edit/Delete links for stored
  (non-config, non-trusted) queries the actor is allowed to manage.

Permission semantics (already enforced by default_query_permissions_sql)
are surfaced in the UI: owners can always edit/delete their queries;
non-private queries can be edited/deleted by any actor with the relevant
permission; private queries remain owner-only.

Shared the create-query form styles into _query_form_styles.html so the
edit form can reuse them.

https://claude.ai/code/session_019GU9g3pZAERukLKYNa4uAL
This commit is contained in:
Claude 2026-06-01 21:00:04 +00:00
commit 5b6cf45568
No known key found for this signature in database
13 changed files with 825 additions and 135 deletions

View file

@ -77,6 +77,7 @@ def documented_views():
"QueryCreateAnalyzeView",
"QueryDeleteView",
"QueryDefinitionView",
"QueryEditView",
"QueryListView",
"QueryParametersView",
"QueryStoreView",

View file

@ -1114,6 +1114,222 @@ async def test_query_update_api_rejects_trusted_queries_but_internal_update_allo
assert query.title == "Internal"
async def _make_ds_with_user_query(name, *, is_private=False, owner_id="owner"):
ds = Datasette(memory=True, settings={"default_allow_sql": True})
db = ds.add_memory_database(name, name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
await ds.add_query(
"data",
"saved",
"select * from dogs",
title="Saved query",
description="A saved query",
source="user",
owner_id=owner_id,
is_private=is_private,
)
return ds
@pytest.mark.asyncio
async def test_query_edit_form_renders_and_updates_for_owner():
ds = await _make_ds_with_user_query("query_edit_owner")
actor = {"id": "owner"}
# GET renders the form pre-filled with existing values
get_response = await ds.client.get("/data/saved/-/edit", actor=actor)
assert get_response.status_code == 200
assert 'value="Saved query"' in get_response.text
assert ">A saved query</textarea>" in get_response.text
assert "select * from dogs" in get_response.text
# URL slug is shown but not editable
assert 'name="name"' not in get_response.text
# POST updates the query and redirects back to the query page
post_response = await ds.client.post(
"/data/saved/-/edit",
actor=actor,
data={
"title": "Updated title",
"description": "Updated description",
"sql": "select id from dogs",
"is_private": "1",
},
)
assert post_response.status_code == 302
assert post_response.headers["location"] == "/data/saved"
query = await ds.get_query("data", "saved")
assert query.title == "Updated title"
assert query.description == "Updated description"
assert query.sql == "select id from dogs"
assert query.is_private is True
@pytest.mark.asyncio
async def test_query_edit_metadata_only_does_not_require_execute_sql():
# An owner who can no longer execute SQL can still edit title/description
ds = await _make_ds_with_user_query("query_edit_metadata_only")
actor = {"id": "owner"}
post_response = await ds.client.post(
"/data/saved/-/edit",
actor=actor,
data={
"title": "Renamed",
"description": "A saved query",
"sql": "select * from dogs",
},
)
assert post_response.status_code == 302
query = await ds.get_query("data", "saved")
assert query.title == "Renamed"
@pytest.mark.asyncio
async def test_private_query_edit_delete_restricted_to_owner():
ds = await _make_ds_with_user_query(
"query_edit_private", is_private=True, owner_id="owner"
)
# A different actor cannot view, edit or delete the private query
other = {"id": "intruder"}
assert (await ds.client.get("/data/saved/-/edit", actor=other)).status_code == 403
assert (
await ds.client.get("/data/saved/-/delete", actor=other)
).status_code == 403
delete_attempt = await ds.client.post(
"/data/saved/-/delete",
actor=other,
data={},
)
assert delete_attempt.status_code == 403
assert await ds.get_query("data", "saved") is not None
# The owner can edit and delete
owner = {"id": "owner"}
assert (await ds.client.get("/data/saved/-/edit", actor=owner)).status_code == 200
@pytest.mark.asyncio
async def test_non_private_query_editable_by_permitted_non_owner():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"execute-sql": {"id": "editor"},
"update-query": {"id": "editor"},
"delete-query": {"id": "editor"},
}
}
}
},
)
db = ds.add_memory_database("query_non_private_editor", name="data")
await db.execute_write("create table dogs (id integer primary key, name text)")
await ds.invoke_startup()
await ds.add_query(
"data",
"saved",
"select * from dogs",
title="Shared",
source="user",
owner_id="owner",
is_private=False,
)
editor = {"id": "editor"}
# Editor (not the owner) can edit because the query is not private
post_response = await ds.client.post(
"/data/saved/-/edit",
actor=editor,
data={
"title": "Edited by editor",
"description": "",
"sql": "select * from dogs",
},
)
assert post_response.status_code == 302
query = await ds.get_query("data", "saved")
assert query.title == "Edited by editor"
# Editor can also delete it
delete_response = await ds.client.post(
"/data/saved/-/delete",
actor=editor,
data={},
)
assert delete_response.status_code == 302
assert await ds.get_query("data", "saved") is None
@pytest.mark.asyncio
async def test_query_delete_confirmation_and_form_delete():
ds = await _make_ds_with_user_query("query_delete_form")
actor = {"id": "owner"}
get_response = await ds.client.get("/data/saved/-/delete", actor=actor)
assert get_response.status_code == 200
assert "Are you sure" in get_response.text
assert "select * from dogs" in get_response.text
post_response = await ds.client.post(
"/data/saved/-/delete",
actor=actor,
data={},
)
assert post_response.status_code == 302
assert post_response.headers["location"] == "/data/-/queries"
assert await ds.get_query("data", "saved") is None
@pytest.mark.asyncio
async def test_query_action_menu_shows_edit_and_delete_for_owner():
ds = await _make_ds_with_user_query("query_action_menu")
owner_response = await ds.client.get("/data/saved", actor={"id": "owner"})
assert owner_response.status_code == 200
assert "/data/saved/-/edit" in owner_response.text
assert "/data/saved/-/delete" in owner_response.text
# A different actor (the query is public) cannot edit/delete by default
other_response = await ds.client.get("/data/saved", actor={"id": "stranger"})
assert other_response.status_code == 200
assert "/data/saved/-/edit" not in other_response.text
assert "/data/saved/-/delete" not in other_response.text
@pytest.mark.asyncio
async def test_query_edit_rejected_for_trusted_query():
ds = Datasette(
memory=True,
default_deny=True,
config={
"databases": {
"data": {
"permissions": {
"execute-sql": {"id": "editor"},
"update-query": {"id": "editor"},
},
"queries": {"trusted_report": {"sql": "select 1 as one"}},
}
}
},
)
ds.add_memory_database("query_edit_trusted", name="data")
await ds.invoke_startup()
response = await ds.client.get("/data/trusted_report/-/edit", actor={"id": "editor"})
assert response.status_code == 403
# Edit/delete links should not appear on a trusted/config query page
page = await ds.client.get("/data/trusted_report", actor={"id": "editor"})
assert "/data/trusted_report/-/edit" not in page.text
@pytest.mark.asyncio
async def test_query_store_api_rejects_magic_parameters():
ds = Datasette(memory=True, default_deny=True)