From 00632ded30e7cf9f0cf9478680645d1dabe269ae Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sun, 30 Oct 2022 16:16:00 -0700 Subject: [PATCH] Initial attempt at /db/table/row/-/delete, refs #1864 --- datasette/app.py | 6 +++- datasette/default_permissions.py | 8 ++++- datasette/views/row.py | 49 +++++++++++++++++++++++++++-- docs/authentication.rst | 12 +++++++ docs/json_api.rst | 19 +++++++++++ tests/test_api_write.py | 54 ++++++++++++++++++++++++++++++++ 6 files changed, 144 insertions(+), 4 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index b53144d1..f5f3c048 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -41,7 +41,7 @@ from .views.special import ( MessagesDebugView, ) from .views.table import TableView, TableInsertView, TableDropView -from .views.row import RowView +from .views.row import RowView, RowDeleteView from .renderer import json_renderer from .url_builder import Urls from .database import Database, QueryInterrupted @@ -1280,6 +1280,10 @@ class Datasette: TableDropView.as_view(self), r"/(?P[^\/\.]+)/(?P[^\/\.]+)/-/drop$", ) + add_route( + RowDeleteView.as_view(self), + r"/(?P[^\/\.]+)/(?P
[^/]+?)/(?P[^/]+?)/-/delete$", + ) return [ # Compile any strings to regular expressions ((re.compile(pattern) if isinstance(pattern, str) else pattern), view) diff --git a/datasette/default_permissions.py b/datasette/default_permissions.py index 3c781317..32b0c758 100644 --- a/datasette/default_permissions.py +++ b/datasette/default_permissions.py @@ -9,7 +9,13 @@ import time @hookimpl(tryfirst=True) def permission_allowed(datasette, actor, action, resource): async def inner(): - if action in ("permissions-debug", "debug-menu", "insert-row", "drop-table"): + if action in ( + "permissions-debug", + "debug-menu", + "insert-row", + "drop-table", + "delete-row", + ): if actor and actor.get("id") == "root": return True elif action == "view-instance": diff --git a/datasette/views/row.py b/datasette/views/row.py index cdbf0990..2fdbb251 100644 --- a/datasette/views/row.py +++ b/datasette/views/row.py @@ -1,15 +1,20 @@ -from datasette.utils.asgi import NotFound, Forbidden +from datasette.utils.asgi import NotFound, Forbidden, Response from datasette.database import QueryInterrupted -from .base import DataView +from .base import DataView, BaseView from datasette.utils import ( tilde_decode, urlsafe_components, to_css_class, escape_sqlite, ) +import sqlite_utils from .table import _sql_params_pks, display_columns_and_rows +def _error(messages, status=400): + return Response.json({"ok": False, "errors": messages}, status=status) + + class RowView(DataView): name = "row" @@ -146,3 +151,43 @@ class RowView(DataView): ) foreign_key_tables.append({**fk, **{"count": count, "link": link}}) return foreign_key_tables + + +class RowDeleteView(BaseView): + name = "row-delete" + + def __init__(self, datasette): + self.ds = datasette + + async def post(self, request): + database_route = tilde_decode(request.url_vars["database"]) + table = tilde_decode(request.url_vars["table"]) + try: + db = self.ds.get_database(route=database_route) + except KeyError: + return _error(["Database not found: {}".format(database_route)], 404) + + database_name = db.name + if not await db.table_exists(table): + return _error(["Table not found: {}".format(table)], 404) + + pk_values = urlsafe_components(request.url_vars["pks"]) + + sql, params, pks = await _sql_params_pks(db, table, pk_values) + results = await db.execute(sql, params, truncate=True) + rows = list(results.rows) + if not rows: + return _error([f"Record not found: {pk_values}"], 404) + + # Ensure user has permission to delete this row + if not await self.ds.permission_allowed( + request.actor, "delete-row", resource=(database_name, table) + ): + return _error(["Permission denied"], 403) + + # Delete table + def delete_row(conn): + sqlite_utils.Database(conn)[table].delete(pk_values) + + await db.execute_write_fn(delete_row) + return Response.json({"ok": True}, status=200) diff --git a/docs/authentication.rst b/docs/authentication.rst index e0796bc8..9ebe4b00 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -559,6 +559,18 @@ Actor is allowed to insert rows into a table. Default *deny*. +.. _permissions_delete_row: + +delete-row +---------- + +Actor is allowed to delete rows from a table. + +``resource`` - tuple: (string, string) + The name of the database, then the name of the table + +Default *deny*. + .. _permissions_drop_table: drop-table diff --git a/docs/json_api.rst b/docs/json_api.rst index cf7eaefc..da4500ab 100644 --- a/docs/json_api.rst +++ b/docs/json_api.rst @@ -540,6 +540,25 @@ To return the newly inserted rows, add the ``"return_rows": true`` key to the re This will return the same ``"inserted"`` key as the single row example above. There is a small performance penalty for using this option. +.. _RowDeleteView: + +Deleting rows +~~~~~~~~~~~~~ + +To delete a row, make a ``POST`` to ``//
//-/delete``. This requires the :ref:`permissions_delete_row` permission. + +:: + + POST //
//-/delete + Content-Type: application/json + Authorization: Bearer dstok_ + +```` here is the :ref:`tilde-encoded ` primary key value of the row to delete - or a comma-separated list of primary key values if the table has a composite primary key. + +If successful, this will return a ``200`` status code and a ``{"ok": true}`` response body. + +Any errors will return ``{"errors": ["... descriptive message ..."], "ok": false}``, and a ``400`` status code for a bad input or a ``403`` status code for an authentication or permission error. + .. _TableDropView: Dropping tables diff --git a/tests/test_api_write.py b/tests/test_api_write.py index 79f905f7..1cfba104 100644 --- a/tests/test_api_write.py +++ b/tests/test_api_write.py @@ -196,6 +196,60 @@ async def test_write_row_errors( assert response.json()["errors"] == expected_errors +@pytest.mark.asyncio +@pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm")) +async def test_delete_row(ds_write, scenario): + if scenario == "no_token": + token = "bad_token" + elif scenario == "no_perm": + token = write_token(ds_write, actor_id="not-root") + else: + token = write_token(ds_write) + should_work = scenario == "has_perm" + + # Insert a row + insert_response = await ds_write.client.post( + "/data/docs/-/insert", + json={"row": {"title": "Row one", "score": 1.0}, "return_rows": True}, + headers={ + "Authorization": "Bearer {}".format(write_token(ds_write)), + "Content-Type": "application/json", + }, + ) + assert insert_response.status_code == 201 + pk = insert_response.json()["inserted"][0]["id"] + + path = "/data/{}/{}/-/delete".format( + "docs" if scenario != "bad_table" else "bad_table", pk + ) + response = await ds_write.client.post( + path, + headers={ + "Authorization": "Bearer {}".format(token), + "Content-Type": "application/json", + }, + ) + if should_work: + assert response.status_code == 200 + assert response.json() == {"ok": True} + assert (await ds_write.client.get("/data/docs.json?_shape=array")).json() == [] + else: + assert ( + response.status_code == 403 + if scenario in ("no_token", "bad_token") + else 404 + ) + assert response.json()["ok"] is False + assert ( + response.json()["errors"] == ["Permission denied"] + if scenario == "no_token" + else ["Table not found: bad_table"] + ) + assert ( + len((await ds_write.client.get("/data/docs.json?_shape=array")).json()) == 1 + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("scenario", ("no_token", "no_perm", "bad_table", "has_perm")) async def test_drop_table(ds_write, scenario):