datasette-write 0.3.1__py3-none-any.whl → 0.5a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,56 +1,72 @@
1
1
  from datasette import hookimpl, Forbidden, Response
2
+ from datasette.permissions import Action
3
+ from datasette.resources import DatabaseResource
2
4
  from datasette.utils import derive_named_parameters
5
+ import itsdangerous
3
6
  from urllib.parse import urlencode
4
7
  import re
5
8
 
9
+ WRITE_ACTION = "datasette-write"
10
+
11
+
12
+ async def _allowed_for_database(datasette, actor, database_name):
13
+ return await datasette.allowed(
14
+ action=WRITE_ACTION,
15
+ actor=actor,
16
+ resource=DatabaseResource(database_name),
17
+ )
18
+
19
+
20
+ async def _require_write_permission(datasette, actor, database_name):
21
+ if not await _allowed_for_database(datasette, actor, database_name):
22
+ raise Forbidden(f"Permission denied for {WRITE_ACTION}")
23
+
6
24
 
7
25
  async def write(request, datasette):
8
- if not await datasette.permission_allowed(
9
- request.actor, "datasette-write", default=False
10
- ):
11
- raise Forbidden("Permission denied for datasette-write")
12
- databases = [
13
- db
14
- for db in datasette.databases.values()
15
- if db.is_mutable and db.name != "_internal"
16
- ]
26
+ database_name = request.url_vars["database"]
27
+ await _require_write_permission(datasette, request.actor, database_name)
17
28
  if request.method == "GET":
18
- selected_database = request.args.get("database") or ""
19
- if not selected_database or selected_database == "_internal":
20
- selected_database = databases[0].name
21
- database = datasette.get_database(selected_database)
29
+ database = datasette.get_database(database_name)
22
30
  tables = await database.table_names()
23
31
  views = await database.view_names()
24
32
  sql = request.args.get("sql") or ""
33
+ parameters = await derive_parameters(database, sql)
34
+ # Set values based on incoming request query string
35
+ for parameter in parameters:
36
+ parameter["value"] = request.args.get(parameter["name"]) or ""
37
+ custom_title = ""
38
+ try:
39
+ custom_title = datasette.unsign(
40
+ request.args.get("_title", ""), "query_title"
41
+ )
42
+ except itsdangerous.BadSignature:
43
+ pass
25
44
  return Response.html(
26
45
  await datasette.render_template(
27
46
  "datasette_write.html",
28
47
  {
29
- "databases": databases,
48
+ "custom_title": custom_title,
30
49
  "sql_from_args": sql,
31
- "selected_database": selected_database,
32
- "parameters": await derive_parameters(database, sql),
50
+ "parameters": parameters,
51
+ "database_name": database_name,
33
52
  "tables": tables,
34
53
  "views": views,
54
+ "redirect_to": request.args.get("_redirect_to"),
55
+ "sql_textarea_height": max(10, int(1.4 * len(sql.split("\n")))),
35
56
  },
36
57
  request=request,
37
58
  )
38
59
  )
39
60
  elif request.method == "POST":
40
61
  formdata = await request.post_vars()
41
- database_name = formdata["database"]
42
62
  sql = formdata["sql"]
43
- try:
44
- database = [db for db in databases if db.name == database_name][0]
45
- except IndexError:
46
- return Response.html("Database not found", status_code=404)
63
+ database = datasette.get_database(database_name)
47
64
 
48
65
  result = None
49
66
  message = None
50
67
  params = {
51
68
  key[3:]: value for key, value in formdata.items() if key.startswith("qp_")
52
69
  }
53
- print(params)
54
70
  try:
55
71
  result = await database.execute_write(sql, params, block=True)
56
72
  if result.rowcount == -1:
@@ -80,44 +96,70 @@ async def write(request, datasette):
80
96
  message,
81
97
  type=datasette.INFO if result else datasette.ERROR,
82
98
  )
83
- return Response.redirect(
84
- datasette.urls.path("/-/write?")
85
- + urlencode(
86
- {
87
- "database": database.name,
88
- "sql": sql,
89
- }
90
- )
99
+ # Default redirect back to this page
100
+ redirect_to = datasette.urls.path("/-/write?") + urlencode(
101
+ {
102
+ "database": database.name,
103
+ "sql": sql,
104
+ }
91
105
  )
106
+ try:
107
+ # Unless value and valid signature for _redirect_to=
108
+ redirect_to = datasette.unsign(formdata["_redirect_to"], "redirect_to")
109
+ except (itsdangerous.BadSignature, KeyError):
110
+ pass
111
+ return Response.redirect(redirect_to)
92
112
  else:
93
113
  return Response.html("Bad method", status_code=405)
94
114
 
95
115
 
116
+ async def write_redirect(request, datasette):
117
+ db = request.args.get("database") or ""
118
+ if not db:
119
+ db = datasette.get_database().name
120
+ await _require_write_permission(datasette, request.actor, db)
121
+
122
+ # Preserve query string, except the database=
123
+ pairs = [
124
+ (key, request.args.getlist(key)) for key in request.args if key != "database"
125
+ ]
126
+ query_string = ""
127
+ if pairs:
128
+ query_string = "?" + urlencode(pairs, doseq=True)
129
+
130
+ return Response.redirect(datasette.urls.database(db) + "/-/write" + query_string)
131
+
132
+
96
133
  async def derive_parameters(db, sql):
97
134
  parameters = await derive_named_parameters(db, sql)
135
+
136
+ def _type(parameter):
137
+ type = "text"
138
+ if parameter.endswith("_textarea"):
139
+ type = "textarea"
140
+ if parameter.endswith("_hidden"):
141
+ type = "hidden"
142
+ return type
143
+
144
+ def _label(parameter):
145
+ if parameter.endswith("_textarea"):
146
+ return parameter[: -len("_textarea")]
147
+ if parameter.endswith("_hidden"):
148
+ return parameter[: -len("_hidden")]
149
+ return parameter
150
+
98
151
  return [
99
- {
100
- "name": parameter,
101
- "type": "textarea" if parameter.endswith("textarea") else "text",
102
- "label": (
103
- parameter.replace("_textarea", "")
104
- if parameter.endswith("textarea")
105
- else parameter
106
- ),
107
- }
152
+ {"name": parameter, "type": _type(parameter), "label": _label(parameter)}
108
153
  for parameter in parameters
109
154
  ]
110
155
 
111
156
 
112
157
  async def write_derive_parameters(datasette, request):
113
- if not await datasette.permission_allowed(
114
- request.actor, "datasette-write", default=False
115
- ):
116
- raise Forbidden("Permission denied for datasette-write")
117
158
  try:
118
159
  db = datasette.get_database(request.args.get("database"))
119
160
  except KeyError:
120
161
  db = datasette.get_database()
162
+ await _require_write_permission(datasette, request.actor, db.name)
121
163
  parameters = await derive_parameters(db, request.args.get("sql") or "")
122
164
  return Response.json({"parameters": parameters})
123
165
 
@@ -125,25 +167,21 @@ async def write_derive_parameters(datasette, request):
125
167
  @hookimpl
126
168
  def register_routes():
127
169
  return [
128
- (r"^/-/write$", write),
170
+ (r"^/(?P<database>[^/]+)/-/write$", write),
171
+ (r"^/-/write$", write_redirect),
129
172
  (r"^/-/write/derive-parameters$", write_derive_parameters),
130
173
  ]
131
174
 
132
175
 
133
176
  @hookimpl
134
- def permission_allowed(actor, action):
135
- if action == "datasette-write" and actor and actor.get("id") == "root":
136
- return True
137
-
138
-
139
- @hookimpl
140
- def menu_links(datasette, actor):
177
+ def database_actions(datasette, actor, database):
141
178
  async def inner():
142
- if await datasette.permission_allowed(actor, "datasette-write", default=False):
179
+ if await _allowed_for_database(datasette, actor, database):
143
180
  return [
144
181
  {
145
- "href": datasette.urls.path("/-/write"),
182
+ "href": datasette.urls.database(database) + "/-/write",
146
183
  "label": "Execute SQL write",
184
+ "description": "Run queries like insert/update/delete against this database",
147
185
  },
148
186
  ]
149
187
 
@@ -151,29 +189,83 @@ def menu_links(datasette, actor):
151
189
 
152
190
 
153
191
  @hookimpl
154
- def database_actions(datasette, actor, database):
192
+ def row_actions(datasette, actor, database, table, row, request):
155
193
  async def inner():
156
- if database != "_internal" and await datasette.permission_allowed(
157
- actor, "datasette-write", default=False
158
- ):
194
+ if await _allowed_for_database(datasette, actor, database):
195
+ db = datasette.get_database(database)
196
+ pks = []
197
+ columns = []
198
+ for details in await db.table_column_details(table):
199
+ if details.is_pk:
200
+ pks.append(details.name)
201
+ else:
202
+ columns.append(
203
+ {
204
+ "name": details.name,
205
+ "notnull": details.notnull,
206
+ }
207
+ )
208
+ row_dict = dict(row)
209
+ set_clause_bits = []
210
+ args = {
211
+ "database": database,
212
+ }
213
+ for column in columns:
214
+ column_name = column["name"]
215
+ field_name = column_name
216
+ if column_name in ("sql", "_redirect_to", "_title"):
217
+ field_name = "_{}".format(column_name)
218
+ current_value = str(row_dict.get(column_name) or "")
219
+ if "\n" in current_value:
220
+ field_name = field_name + "_textarea"
221
+ if column["notnull"]:
222
+ fragment = '"{}" = :{}'
223
+ else:
224
+ fragment = "\"{}\" = nullif(:{}, '')"
225
+ set_clause_bits.append(fragment.format(column["name"], field_name))
226
+ args[field_name] = current_value
227
+ set_clauses = ",\n ".join(set_clause_bits)
228
+
229
+ # Add the where clauses, with _hidden to prevent edits
230
+ where_clauses = " and ".join(
231
+ '"{}" = :{}_hidden'.format(pk, pk) for pk in pks
232
+ )
233
+ args.update([("{}_hidden".format(pk), row_dict[pk]) for pk in pks])
234
+
235
+ row_desc = ", ".join(
236
+ "{}={}".format(k, v) for k, v in row_dict.items() if k in pks
237
+ )
238
+
239
+ sql = 'update "{}" set\n {}\nwhere {}'.format(
240
+ table, set_clauses, where_clauses
241
+ )
242
+ args["sql"] = sql
243
+ args["_redirect_to"] = datasette.sign(request.path, "redirect_to")
244
+ args["_title"] = datasette.sign(
245
+ "Update {} where {}".format(table, row_desc), "query_title"
246
+ )
159
247
  return [
160
248
  {
161
- "href": datasette.urls.path(
162
- "/-/write?"
163
- + urlencode(
164
- {
165
- "database": database,
166
- }
167
- )
168
- ),
169
- "label": "Execute SQL write",
170
- "description": "Run queries like insert/update/delete against this database",
249
+ "href": datasette.urls.path("/-/write") + "?" + urlencode(args),
250
+ "label": "Update using SQL",
251
+ "description": "Compose and execute a SQL query to update this row",
171
252
  },
172
253
  ]
173
254
 
174
255
  return inner
175
256
 
176
257
 
258
+ @hookimpl
259
+ def register_actions(datasette):
260
+ return [
261
+ Action(
262
+ name=WRITE_ACTION,
263
+ description="Execute SQL write queries against a database",
264
+ resource_class=DatabaseResource,
265
+ ),
266
+ ]
267
+
268
+
177
269
  _name_patterns = (
178
270
  r"\[([^\]]+)\]", # create table [foo]
179
271
  r'"([^"]+)"', # create table "foo"
@@ -1,6 +1,6 @@
1
1
  {% extends "base.html" %}
2
2
 
3
- {% block title %}Write with SQL{% endblock %}
3
+ {% block title %}{% if custom_title %}{{ custom_title }}{% else %}Write to {{ database_name }} with SQL{% endif %}{% endblock %}
4
4
 
5
5
  {% block extra_head %}
6
6
  <style>
@@ -25,7 +25,7 @@
25
25
  box-sizing: border-box;
26
26
  }
27
27
  #query-parameters-area textarea {
28
- height: 8em;
28
+ height: 20em;
29
29
  }
30
30
  .submit-container {
31
31
  padding-top: 1em;
@@ -36,28 +36,34 @@
36
36
  </style>
37
37
  {% endblock %}
38
38
 
39
+ {% block crumbs %}
40
+ {{ crumbs.nav(request=request, database=database_name) }}
41
+ {% endblock %}
42
+
39
43
  {% block content %}
40
- <h1>Write to the database with SQL</h1>
44
+ <h1>{% if custom_title %}{{ custom_title }}{% else %}Write to {{ database_name }} with SQL{% endif %}</h1>
41
45
 
42
- <form class="write-form" action="{{ base_url }}-/write" method="post" style="margin-bottom: 1em">
46
+ <form class="write-form core" action="{{ request.path }}" method="post" style="margin-bottom: 1em">
43
47
  <input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
44
- <p class="database-select"><label>Database: <select name="database">{% for database in databases %}
45
- <option{% if database.name == selected_database %} selected="selected"{% endif %}>{{ database.name }}</option>
46
- {% endfor %}</select></label></p>
47
- <p><textarea name="sql" style="box-sizing: border-box; width: 100%; padding-right: 10px; max-width: 600px; height: 10em; padding: 6px;">{{ sql_from_args }}</textarea></p>
48
+ {% if custom_title %}<details style="margin-bottom: 1em"><summary>SQL query</summary>{% endif %}
49
+ <p><textarea name="sql" style="box-sizing: border-box; width: 100%; padding-right: 10px; max-width: 600px; height: {{ sql_textarea_height }}em; padding: 6px;">{{ sql_from_args }}</textarea></p>
50
+ {% if custom_title %}</details>{% endif %}
48
51
  <div class="query-parameters">
49
52
  <div id="query-parameters-area">
50
53
  {% for parameter in parameters %}
51
54
  <label for="qp_{{ parameter.name }}">{{ parameter.label }}</label>
52
55
  {% if parameter.type == "text" %}
53
- <input type="text" id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}" value="">
56
+ <input type="text" id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}" value="{{ parameter.value }}">
57
+ {% elif parameter.type == "hidden" %}
58
+ <input type="text" disabled id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}" value="{{ parameter.value }}">
54
59
  {% elif parameter.type == "textarea" %}
55
- <textarea id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}"></textarea>
60
+ <textarea id="qp_{{ parameter.name }}" name="qp_{{ parameter.name }}">{{ parameter.value }}</textarea>
56
61
  {% endif %}
57
62
  {% endfor %}
58
63
  </div>
59
64
  </div>
60
65
  <div class="submit-container">
66
+ <input type="hidden" name="_redirect_to" value="{{ redirect_to }}">
61
67
  <input type="submit" value="Execute query">
62
68
  </div>
63
69
  </form>
@@ -65,7 +71,7 @@
65
71
  {% if tables %}
66
72
  <p><strong>Tables</strong>:
67
73
  {% for table in tables %}
68
- <a href="{{ urls.table(selected_database, table) }}">{{ table }}</a>{% if not loop.last %}, {% endif %}
74
+ <a href="{{ urls.table(database_name, table) }}">{{ table }}</a>{% if not loop.last %}, {% endif %}
69
75
  {% endfor %}
70
76
  </p>
71
77
  {% endif %}
@@ -73,7 +79,7 @@
73
79
  {% if views %}
74
80
  <p><strong>Views</strong>:
75
81
  {% for view in views %}
76
- <a href="{{ urls.table(selected_database, view) }}">{{ view }}</a>{% if not loop.last %}, {% endif %}
82
+ <a href="{{ urls.table(database_name, view) }}">{{ view }}</a>{% if not loop.last %}, {% endif %}
77
83
  {% endfor %}
78
84
  </p>
79
85
  {% endif %}
@@ -81,16 +87,42 @@
81
87
  <script>
82
88
  function buildQueryParametersHtml(parameters) {
83
89
  let htmlString = '';
90
+ var qsParams = new URLSearchParams(location.search);
84
91
  parameters.forEach(param => {
92
+ const value = escapeHtml(qsParams.get(param.name) || '');
85
93
  if (param.type === 'text') {
86
- htmlString += `<label for="qp_${param.name}">${param.label}</label> <input type="text" id="qp_${param.name}" name="qp_${param.name}" value="">\n`;
94
+ htmlString += `
95
+ <label for="qp_${param.name}">${param.label}</label>
96
+ <input type="text" id="qp_${param.name}" name="qp_${param.name}" value="${value}">
97
+ `;
87
98
  } else if (param.type === 'textarea') {
88
- htmlString += `<label for="qp_${param.name}">${param.label}</label> <textarea id="qp_${param.name}" name="qp_${param.name}"></textarea>\n`;
99
+ htmlString += `
100
+ <label for="qp_${param.name}">${param.label}</label>
101
+ <textarea id="qp_${param.name}" name="qp_${param.name}">${value}</textarea>
102
+ `;
103
+ } else if (param.type === 'hidden') {
104
+ htmlString += `
105
+ <span></span>
106
+ <input type="hidden" id="qp_${param.name}" name="qp_${param.name}" value="${value}">
107
+ `;
89
108
  }
90
109
  });
91
110
  return htmlString;
92
111
  }
93
112
 
113
+ function escapeHtml(str) {
114
+ return str.replace(/[&<>"']/g, function(match) {
115
+ switch (match) {
116
+ case '&': return '&amp;';
117
+ case '<': return '&lt;';
118
+ case '>': return '&gt;';
119
+ case '"': return '&quot;';
120
+ case "'": return '&#39;';
121
+ default: return match;
122
+ }
123
+ });
124
+ }
125
+
94
126
  const sqlTextArea = document.querySelector('textarea[name="sql"]');
95
127
  const queryParametersArea = document.getElementById('query-parameters-area');
96
128
  let lastRequestTime = 0;
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: datasette-write
3
- Version: 0.3.1
3
+ Version: 0.5a0
4
4
  Summary: Datasette plugin providing a UI for writing to a database
5
5
  Home-page: https://github.com/simonw/datasette-write
6
6
  Author: Simon Willison
@@ -8,12 +8,24 @@ License: Apache License, Version 2.0
8
8
  Project-URL: Issues, https://github.com/simonw/datasette-write/issues
9
9
  Project-URL: CI, https://github.com/simonw/datasette-write/actions
10
10
  Project-URL: Changelog, https://github.com/simonw/datasette-write/releases
11
+ Requires-Python: >=3.10
11
12
  Description-Content-Type: text/markdown
12
- Requires-Dist: datasette >=0.64.6
13
+ Requires-Dist: datasette>=1.0a21
13
14
  Provides-Extra: test
14
- Requires-Dist: pytest ; extra == 'test'
15
- Requires-Dist: pytest-asyncio ; extra == 'test'
16
- Requires-Dist: httpx ; extra == 'test'
15
+ Requires-Dist: pytest; extra == "test"
16
+ Requires-Dist: pytest-asyncio; extra == "test"
17
+ Requires-Dist: httpx; extra == "test"
18
+ Requires-Dist: beautifulsoup4; extra == "test"
19
+ Dynamic: author
20
+ Dynamic: description
21
+ Dynamic: description-content-type
22
+ Dynamic: home-page
23
+ Dynamic: license
24
+ Dynamic: project-url
25
+ Dynamic: provides-extra
26
+ Dynamic: requires-dist
27
+ Dynamic: requires-python
28
+ Dynamic: summary
17
29
 
18
30
  # datasette-write
19
31
 
@@ -32,13 +44,13 @@ pip install datasette-write
32
44
  ```
33
45
  ## Usage
34
46
 
35
- Having installed the plugin, visit `/-/write` on your Datasette instance to submit SQL queries that will be executed against a write connection to the specified database.
47
+ Having installed the plugin, visit `/db/-/write` on your Datasette instance to submit SQL queries that will be executed against a write connection to the specified database.
36
48
 
37
49
  By default only the `root` user can access the page - so you'll need to run Datasette with the `--root` option and click on the link shown in the terminal to sign in and access the page.
38
50
 
39
51
  The `datasette-write` permission governs access. You can use permission plugins such as [datasette-permissions-sql](https://github.com/simonw/datasette-permissions-sql) to grant additional access to the write interface.
40
52
 
41
- Pass `?sql=...` in the query string to pre-populate the SQL editor with a query. Pass `?database=...` to specify a database to run the query against.
53
+ Pass `?sql=...` in the query string to pre-populate the SQL editor with a query.
42
54
 
43
55
  ## Parameterized queries
44
56
 
@@ -47,10 +59,16 @@ SQL queries can include parameters like this:
47
59
  insert into news (title, body)
48
60
  values (:title, :body_textarea)
49
61
  ```
50
- These will be converted into form fields on the `/-/write` page.
62
+ These will be converted into form fields on the `/db/-/write` page.
51
63
 
52
64
  If a parameter name ends with `_textarea` it will be rendered as a multi-line textarea instead of a text input.
53
65
 
66
+ If a parameter name ends with `_hidden` it will be rendered as a hidden input.
67
+
68
+ ## Updating rows with SQL
69
+
70
+ On Datasette 1.0a13 and higher a row actions menu item will be added to the row page linking to a SQL query for updating that row, for users with the `datasette-write` permission.
71
+
54
72
  ## Development
55
73
 
56
74
  To set up this plugin locally, first checkout the code. Then create a new virtual environment:
@@ -0,0 +1,7 @@
1
+ datasette_write/__init__.py,sha256=X9M-RQSVHAKbdtsZm_p0Zo5a_HfmQNPW7qQQnClvt4Y,10374
2
+ datasette_write/templates/datasette_write.html,sha256=SMcaO3eSw7h2nbMYu55pEq8yvvKtg6eOfGlvH4OeLic,6159
3
+ datasette_write-0.5a0.dist-info/METADATA,sha256=2EKcEV7I71E9w-CL7TEfMr_c3f_uCEpwIrn90yK1fro,3343
4
+ datasette_write-0.5a0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
5
+ datasette_write-0.5a0.dist-info/entry_points.txt,sha256=q6Nsr_AVBF5D0y_ojz62qBHP8NQmxcpTzaD5O8ndsJ0,36
6
+ datasette_write-0.5a0.dist-info/top_level.txt,sha256=TqV9H_tIZKzfVqbxQQWAxuu9NrbmOOdziZgsBYuYods,16
7
+ datasette_write-0.5a0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.43.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,7 +0,0 @@
1
- datasette_write/__init__.py,sha256=rLD8QENtvprI3ER2aCKGtNmQ7qjAcWbmLd507kySVvU,6715
2
- datasette_write/templates/datasette_write.html,sha256=-7zwcfznocsei5aIZnRcgEsdCib6-H7Fa1TuxasetDM,4911
3
- datasette_write-0.3.1.dist-info/METADATA,sha256=rJvJt-Z2oXaT_7fvcjUxI35wl4lPMg3wqpGelgm7iDo,2834
4
- datasette_write-0.3.1.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
5
- datasette_write-0.3.1.dist-info/entry_points.txt,sha256=q6Nsr_AVBF5D0y_ojz62qBHP8NQmxcpTzaD5O8ndsJ0,36
6
- datasette_write-0.3.1.dist-info/top_level.txt,sha256=TqV9H_tIZKzfVqbxQQWAxuu9NrbmOOdziZgsBYuYods,16
7
- datasette_write-0.3.1.dist-info/RECORD,,