mirror of
https://github.com/simonw/datasette.git
synced 2026-06-15 05:26:59 +02:00
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:
parent
911954347e
commit
5b6cf45568
13 changed files with 825 additions and 135 deletions
|
|
@ -77,6 +77,7 @@ def documented_views():
|
|||
"QueryCreateAnalyzeView",
|
||||
"QueryDeleteView",
|
||||
"QueryDefinitionView",
|
||||
"QueryEditView",
|
||||
"QueryListView",
|
||||
"QueryParametersView",
|
||||
"QueryStoreView",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue