From 18c5bd1805cb3bda588aa4beb33ca55b90f99a7e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 29 Apr 2020 00:47:12 -0700 Subject: [PATCH 01/11] First working version of writable canned queries, refs #698 --- datasette/static/app.css | 5 ++++ datasette/templates/query.html | 10 ++++++-- datasette/views/database.py | 46 ++++++++++++++++++++++++++++++---- datasette/views/table.py | 18 +++++++++++++ 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index bae091b8..e19b9be1 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -345,3 +345,8 @@ p.zero-results { padding: 0.5em; font-style: italic; } +.success { + padding: 1em; + border: 1px solid green; + background-color: #c7fbc7; +} diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 2c8c05a0..dca80ce3 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -27,11 +27,12 @@ {% endblock %} {% block content %} +

{{ metadata.title or database }}

{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} -
+

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} {% if hide_sql %}(show){% else %}(hide){% endif %}

{% if not hide_sql %} {% if editable and config.allow_sql %} @@ -74,7 +75,12 @@ {% else %} -

0 results

+ {% if success_message %} +

{{ success_message }}

+ {% endif %} + {% if not canned_write %} +

0 results

+ {% endif %} {% endif %} {% include "_codemirror_foot.html" %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 92e24f84..eb5655b3 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -106,6 +106,8 @@ class QueryView(DataView): canned_query=None, metadata=None, _size=None, + named_parameters=None, + write=False, ): params = request.raw_args if "sql" in params: @@ -113,7 +115,7 @@ class QueryView(DataView): if "_shape" in params: params.pop("_shape") # Extract any :named parameters - named_parameters = self.re_named_parameter.findall(sql) + named_parameters = named_parameters or self.re_named_parameter.findall(sql) named_parameter_values = { named_parameter: params.get(named_parameter) or "" for named_parameter in named_parameters @@ -129,12 +131,46 @@ class QueryView(DataView): extra_args["custom_time_limit"] = int(params["_timelimit"]) if _size: extra_args["page_size"] = _size - results = await self.ds.execute( - database, sql, params, truncate=True, **extra_args - ) - columns = [r[0] for r in results.description] templates = ["query-{}.html".format(to_css_class(database)), "query.html"] + + # Execute query - as write or as read + if write: + if request.method == "POST": + params = await request.post_vars() + write_ok = await self.ds.databases[database].execute_write( + sql, params, block=True + ) + return self.redirect(request, request.path + '?_success=Query+executed_successfully') + else: + async def extra_template(): + return { + "request": request, + "path_with_added_args": path_with_added_args, + "path_with_removed_args": path_with_removed_args, + "named_parameter_values": named_parameter_values, + "canned_query": canned_query, + "success_message": request.raw_args.get("_success") or "", + "canned_write": True, + } + + return ( + { + "database": database, + "rows": [], + "truncated": False, + "columns": [], + "query": {"sql": sql, "params": params}, + }, + extra_template, + templates, + ) + else: # Not a write + results = await self.ds.execute( + database, sql, params, truncate=True, **extra_args + ) + columns = [r[0] for r in results.description] + if canned_query: templates.insert( 0, diff --git a/datasette/views/table.py b/datasette/views/table.py index 10e86eeb..676f5b44 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -211,6 +211,22 @@ class RowTableShared(DataView): class TableView(RowTableShared): name = "table" + async def post(self, request, db_name, table_and_format): + # Handle POST to a canned query + canned_query = self.ds.get_canned_query(db_name, table_and_format) + assert canned_query, "You may only POST to a canned query" + return await QueryView(self.ds).data( + request, + db_name, + None, + canned_query["sql"], + metadata=canned_query, + editable=False, + canned_query=table_and_format, + named_parameters=canned_query.get("params"), + write=bool(canned_query.get("write")), + ) + async def data( self, request, @@ -231,6 +247,8 @@ class TableView(RowTableShared): metadata=canned_query, editable=False, canned_query=table, + named_parameters=canned_query.get("params"), + write=bool(canned_query.get("write")), ) db = self.ds.databases[database] From 95bb5181b441b9e36b2ed3cdcf8fa6fd7e8229a5 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 29 Apr 2020 00:50:55 -0700 Subject: [PATCH 02/11] Applied black --- datasette/views/database.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index eb5655b3..1f3c87ce 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -141,8 +141,11 @@ class QueryView(DataView): write_ok = await self.ds.databases[database].execute_write( sql, params, block=True ) - return self.redirect(request, request.path + '?_success=Query+executed_successfully') + return self.redirect( + request, request.path + "?_success=Query+executed_successfully" + ) else: + async def extra_template(): return { "request": request, From 5b252a2348aef3e70e6e436368a1a1df5ea03a4a Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 29 Apr 2020 00:47:12 -0700 Subject: [PATCH 03/11] First working version of writable canned queries, refs #698 --- datasette/static/app.css | 6 +++++ datasette/templates/query.html | 10 ++++++-- datasette/views/database.py | 46 ++++++++++++++++++++++++++++++---- datasette/views/table.py | 18 +++++++++++++ 4 files changed, 73 insertions(+), 7 deletions(-) diff --git a/datasette/static/app.css b/datasette/static/app.css index 92f268ae..b733fc30 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -351,3 +351,9 @@ p.zero-results { .type-float, .type-int { color: #666; } + +.success { + padding: 1em; + border: 1px solid green; + background-color: #c7fbc7; +} diff --git a/datasette/templates/query.html b/datasette/templates/query.html index 2c8c05a0..dca80ce3 100644 --- a/datasette/templates/query.html +++ b/datasette/templates/query.html @@ -27,11 +27,12 @@ {% endblock %} {% block content %} +

{{ metadata.title or database }}

{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %} - +

Custom SQL query{% if display_rows %} returning {% if truncated %}more than {% endif %}{{ "{:,}".format(display_rows|length) }} row{% if display_rows|length == 1 %}{% else %}s{% endif %}{% endif %} {% if hide_sql %}(show){% else %}(hide){% endif %}

{% if not hide_sql %} {% if editable and config.allow_sql %} @@ -74,7 +75,12 @@ {% else %} -

0 results

+ {% if success_message %} +

{{ success_message }}

+ {% endif %} + {% if not canned_write %} +

0 results

+ {% endif %} {% endif %} {% include "_codemirror_foot.html" %} diff --git a/datasette/views/database.py b/datasette/views/database.py index 15545fb8..87538023 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -106,6 +106,8 @@ class QueryView(DataView): canned_query=None, metadata=None, _size=None, + named_parameters=None, + write=False, ): params = {key: request.args.get(key) for key in request.args} if "sql" in params: @@ -113,7 +115,7 @@ class QueryView(DataView): if "_shape" in params: params.pop("_shape") # Extract any :named parameters - named_parameters = self.re_named_parameter.findall(sql) + named_parameters = named_parameters or self.re_named_parameter.findall(sql) named_parameter_values = { named_parameter: params.get(named_parameter) or "" for named_parameter in named_parameters @@ -129,12 +131,46 @@ class QueryView(DataView): extra_args["custom_time_limit"] = int(params["_timelimit"]) if _size: extra_args["page_size"] = _size - results = await self.ds.execute( - database, sql, params, truncate=True, **extra_args - ) - columns = [r[0] for r in results.description] templates = ["query-{}.html".format(to_css_class(database)), "query.html"] + + # Execute query - as write or as read + if write: + if request.method == "POST": + params = await request.post_vars() + write_ok = await self.ds.databases[database].execute_write( + sql, params, block=True + ) + return self.redirect(request, request.path + '?_success=Query+executed_successfully') + else: + async def extra_template(): + return { + "request": request, + "path_with_added_args": path_with_added_args, + "path_with_removed_args": path_with_removed_args, + "named_parameter_values": named_parameter_values, + "canned_query": canned_query, + "success_message": request.raw_args.get("_success") or "", + "canned_write": True, + } + + return ( + { + "database": database, + "rows": [], + "truncated": False, + "columns": [], + "query": {"sql": sql, "params": params}, + }, + extra_template, + templates, + ) + else: # Not a write + results = await self.ds.execute( + database, sql, params, truncate=True, **extra_args + ) + columns = [r[0] for r in results.description] + if canned_query: templates.insert( 0, diff --git a/datasette/views/table.py b/datasette/views/table.py index 2e9515c3..79bf8b08 100644 --- a/datasette/views/table.py +++ b/datasette/views/table.py @@ -221,6 +221,22 @@ class RowTableShared(DataView): class TableView(RowTableShared): name = "table" + async def post(self, request, db_name, table_and_format): + # Handle POST to a canned query + canned_query = self.ds.get_canned_query(db_name, table_and_format) + assert canned_query, "You may only POST to a canned query" + return await QueryView(self.ds).data( + request, + db_name, + None, + canned_query["sql"], + metadata=canned_query, + editable=False, + canned_query=table_and_format, + named_parameters=canned_query.get("params"), + write=bool(canned_query.get("write")), + ) + async def data( self, request, @@ -241,6 +257,8 @@ class TableView(RowTableShared): metadata=canned_query, editable=False, canned_query=table, + named_parameters=canned_query.get("params"), + write=bool(canned_query.get("write")), ) db = self.ds.databases[database] From ee51e0b82c4976eb73b039ffe9a97e248884992e Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Wed, 29 Apr 2020 00:50:55 -0700 Subject: [PATCH 04/11] Applied black --- datasette/views/database.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/datasette/views/database.py b/datasette/views/database.py index 87538023..afe7a411 100644 --- a/datasette/views/database.py +++ b/datasette/views/database.py @@ -141,8 +141,11 @@ class QueryView(DataView): write_ok = await self.ds.databases[database].execute_write( sql, params, block=True ) - return self.redirect(request, request.path + '?_success=Query+executed_successfully') + return self.redirect( + request, request.path + "?_success=Query+executed_successfully" + ) else: + async def extra_template(): return { "request": request, From 6690d6bf7f2444bb7de982cab64f123c5038c976 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Mon, 1 Jun 2020 08:47:16 -0700 Subject: [PATCH 05/11] Internal scope correlation ID, refs #703 --- datasette/app.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index b8a5e23d..8df8daa7 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -10,6 +10,7 @@ import sys import threading import traceback import urllib.parse +import uuid from concurrent import futures from pathlib import Path @@ -856,13 +857,15 @@ class DatasetteRouter(AsgiRouter): base_url = self.ds.config("base_url") if base_url != "/" and path.startswith(base_url): path = "/" + path[len(base_url) :] + # Add a correlation ID + scope_modifications = {"correlation_id": str(uuid.uuid4())} # Apply force_https_urls, if set if ( self.ds.config("force_https_urls") and scope["type"] == "http" and scope.get("scheme") != "https" ): - scope = dict(scope, scheme="https") + scope_modifications["scheme"] = "https" # Handle authentication actor = None for actor in pm.hook.actor_from_request( @@ -874,7 +877,10 @@ class DatasetteRouter(AsgiRouter): actor = await actor if actor: break - return await super().route_path(dict(scope, actor=actor), receive, send, path) + scope_modifications["actor"] = actor + return await super().route_path( + dict(scope, **scope_modifications), receive, send, path + ) async def handle_404(self, scope, receive, send, exception=None): # If URL has a trailing slash, redirect to URL without it From 2c3f086e9845de4e81bfa1cb8aa19785c2f08ff9 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Jun 2020 10:43:50 -0700 Subject: [PATCH 06/11] permission_checks is now _permission_checks --- datasette/app.py | 4 ++-- datasette/views/special.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 8df8daa7..f2482a2c 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -290,7 +290,7 @@ class Datasette: pm.hook.prepare_jinja2_environment(env=self.jinja_env) self._register_renderers() - self.permission_checks = collections.deque(maxlen=30) + self._permission_checks = collections.deque(maxlen=30) self._root_token = os.urandom(32).hex() def sign(self, value, namespace="default"): @@ -446,7 +446,7 @@ class Datasette: if result is None: result = default used_default = True - self.permission_checks.append( + self._permission_checks.append( { "when": datetime.datetime.utcnow().isoformat(), "actor": actor, diff --git a/datasette/views/special.py b/datasette/views/special.py index b75355fb..811ed4cb 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -92,5 +92,5 @@ class PermissionsDebugView(BaseView): return await self.render( ["permissions_debug.html"], request, - {"permission_checks": reversed(self.ds.permission_checks)}, + {"permission_checks": reversed(self.ds._permission_checks)}, ) From 29b8e80f3c7c9f49fa30141e3fa2d2619d2683c3 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Jun 2020 14:06:53 -0700 Subject: [PATCH 07/11] New request.cookies property --- datasette/actor_auth_cookie.py | 9 ++------- datasette/utils/asgi.py | 7 +++++++ docs/internals.rst | 3 +++ 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/datasette/actor_auth_cookie.py b/datasette/actor_auth_cookie.py index 41f33fe9..f3a0f306 100644 --- a/datasette/actor_auth_cookie.py +++ b/datasette/actor_auth_cookie.py @@ -5,14 +5,9 @@ from http.cookies import SimpleCookie @hookimpl def actor_from_request(datasette, request): - cookies = SimpleCookie() - cookies.load( - dict(request.scope.get("headers") or []).get(b"cookie", b"").decode("utf-8") - ) - if "ds_actor" not in cookies: + if "ds_actor" not in request.cookies: return None - ds_actor = cookies["ds_actor"].value try: - return datasette.unsign(ds_actor, "actor") + return datasette.unsign(request.cookies["ds_actor"], "actor") except BadSignature: return None diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 24398b77..960532ca 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -4,6 +4,7 @@ from mimetypes import guess_type from urllib.parse import parse_qs, urlunparse, parse_qsl from pathlib import Path from html import escape +from http.cookies import SimpleCookie import re import aiofiles @@ -44,6 +45,12 @@ class Request: def host(self): return self.headers.get("host") or "localhost" + @property + def cookies(self): + cookies = SimpleCookie() + cookies.load(self.headers.get("cookie", "")) + return {key: value.value for key, value in cookies.items()} + @property def path(self): if self.scope.get("raw_path") is not None: diff --git a/docs/internals.rst b/docs/internals.rst index 68a35312..b3ad623f 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -27,6 +27,9 @@ The request object is passed to various plugin hooks. It represents an incoming ``.headers`` - dictionary (str -> str) A dictionary of incoming HTTP request headers. +``.cookies`` - dictionary (str -> str) + A dictionary of incoming cookies + ``.host`` - string The host header from the incoming request, e.g. ``latest.datasette.io`` or ``localhost``. From 61e40e917efc43a8aea5298a22badbb6eaea3fa1 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Jun 2020 14:08:12 -0700 Subject: [PATCH 08/11] Flash messages mechanism, closes #790 --- datasette/app.py | 42 +++++++++++++++++++++++ datasette/static/app.css | 12 ++++++- datasette/templates/base.html | 8 +++++ datasette/templates/messages_debug.html | 26 ++++++++++++++ datasette/utils/asgi.py | 4 +-- datasette/views/base.py | 16 +++++++++ datasette/views/special.py | 24 +++++++++++++ docs/internals.rst | 18 ++++++++++ docs/introspection.rst | 8 +++++ tests/fixtures.py | 6 ++++ tests/plugins/messages_output_renderer.py | 21 ++++++++++++ tests/test_api.py | 1 + tests/test_auth.py | 6 +--- tests/test_messages.py | 28 +++++++++++++++ 14 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 datasette/templates/messages_debug.html create mode 100644 tests/plugins/messages_output_renderer.py create mode 100644 tests/test_messages.py diff --git a/datasette/app.py b/datasette/app.py index f2482a2c..9f6b783b 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -2,6 +2,7 @@ import asyncio import collections import datetime import hashlib +from http.cookies import SimpleCookie import itertools import json import os @@ -31,6 +32,7 @@ from .views.special import ( PatternPortfolioView, AuthTokenView, PermissionsDebugView, + MessagesDebugView, ) from .views.table import RowView, TableView from .renderer import json_renderer @@ -157,6 +159,11 @@ async def favicon(scope, receive, send): class Datasette: + # Message constants: + INFO = 1 + WARNING = 2 + ERROR = 3 + def __init__( self, files, @@ -424,6 +431,38 @@ class Datasette: # pylint: disable=no-member pm.hook.prepare_connection(conn=conn, database=database, datasette=self) + def add_message(self, request, message, type=INFO): + if not hasattr(request, "_messages"): + request._messages = [] + request._messages_should_clear = False + request._messages.append((message, type)) + + def _write_messages_to_response(self, request, response): + if getattr(request, "_messages", None): + # Set those messages + cookie = SimpleCookie() + cookie["ds_messages"] = self.sign(request._messages, "messages") + cookie["ds_messages"]["path"] = "/" + # TODO: Co-exist with existing set-cookie headers + assert "set-cookie" not in response.headers + response.headers["set-cookie"] = cookie.output(header="").lstrip() + elif getattr(request, "_messages_should_clear", False): + cookie = SimpleCookie() + cookie["ds_messages"] = "" + cookie["ds_messages"]["path"] = "/" + # TODO: Co-exist with existing set-cookie headers + assert "set-cookie" not in response.headers + response.headers["set-cookie"] = cookie.output(header="").lstrip() + + def _show_messages(self, request): + if getattr(request, "_messages", None): + request._messages_should_clear = True + messages = request._messages + request._messages = [] + return messages + else: + return [] + async def permission_allowed( self, actor, action, resource_type=None, resource_identifier=None, default=False ): @@ -809,6 +848,9 @@ class Datasette: add_route( PermissionsDebugView.as_asgi(self), r"/-/permissions$", ) + add_route( + MessagesDebugView.as_asgi(self), r"/-/messages$", + ) add_route( PatternPortfolioView.as_asgi(self), r"/-/patterns$", ) diff --git a/datasette/static/app.css b/datasette/static/app.css index b733fc30..774a2235 100644 --- a/datasette/static/app.css +++ b/datasette/static/app.css @@ -352,8 +352,18 @@ p.zero-results { color: #666; } -.success { +.message-info { padding: 1em; border: 1px solid green; background-color: #c7fbc7; } +.message-warning { + padding: 1em; + border: 1px solid #ae7100; + background-color: #fbdda5; +} +.message-error { + padding: 1em; + border: 1px solid red; + background-color: pink; +} diff --git a/datasette/templates/base.html b/datasette/templates/base.html index d9fd945b..9b871d03 100644 --- a/datasette/templates/base.html +++ b/datasette/templates/base.html @@ -17,6 +17,14 @@
+{% block messages %} +{% if show_messages %} + {% for message, message_type in show_messages() %} +

{{ message }}

+ {% endfor %} +{% endif %} +{% endblock %} + {% block content %} {% endblock %}
diff --git a/datasette/templates/messages_debug.html b/datasette/templates/messages_debug.html new file mode 100644 index 00000000..b2e1bc7c --- /dev/null +++ b/datasette/templates/messages_debug.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}Debug messages{% endblock %} + +{% block content %} + +

Debug messages

+ +

Set a message:

+ + +
+ +
+ +
+ +
+ + +{% endblock %} diff --git a/datasette/utils/asgi.py b/datasette/utils/asgi.py index 960532ca..5682da48 100644 --- a/datasette/utils/asgi.py +++ b/datasette/utils/asgi.py @@ -180,9 +180,9 @@ class AsgiLifespan: class AsgiView: - def dispatch_request(self, request, *args, **kwargs): + async def dispatch_request(self, request, *args, **kwargs): handler = getattr(self, request.method.lower(), None) - return handler(request, *args, **kwargs) + return await handler(request, *args, **kwargs) @classmethod def as_asgi(cls, *class_args, **class_kwargs): diff --git a/datasette/views/base.py b/datasette/views/base.py index 06b78d5f..2402406a 100644 --- a/datasette/views/base.py +++ b/datasette/views/base.py @@ -1,6 +1,7 @@ import asyncio import csv import itertools +from itsdangerous import BadSignature import json import re import time @@ -73,6 +74,20 @@ class BaseView(AsgiView): def database_color(self, database): return "ff0000" + async def dispatch_request(self, request, *args, **kwargs): + # Populate request_messages if ds_messages cookie is present + if self.ds: + try: + request._messages = self.ds.unsign( + request.cookies.get("ds_messages", ""), "messages" + ) + except BadSignature: + pass + response = await super().dispatch_request(request, *args, **kwargs) + if self.ds: + self.ds._write_messages_to_response(request, response) + return response + async def render(self, templates, request, context=None): context = context or {} template = self.ds.jinja_env.select_template(templates) @@ -81,6 +96,7 @@ class BaseView(AsgiView): **{ "database_url": self.database_url, "database_color": self.database_color, + "show_messages": lambda: self.ds._show_messages(request), "select_templates": [ "{}{}".format( "*" if template_name == template.name else "", template_name diff --git a/datasette/views/special.py b/datasette/views/special.py index 811ed4cb..37c04697 100644 --- a/datasette/views/special.py +++ b/datasette/views/special.py @@ -94,3 +94,27 @@ class PermissionsDebugView(BaseView): request, {"permission_checks": reversed(self.ds._permission_checks)}, ) + + +class MessagesDebugView(BaseView): + name = "messages_debug" + + def __init__(self, datasette): + self.ds = datasette + + async def get(self, request): + return await self.render(["messages_debug.html"], request) + + async def post(self, request): + post = await request.post_vars() + message = post.get("message", "") + message_type = post.get("message_type") or "INFO" + assert message_type in ("INFO", "WARNING", "ERROR", "all") + datasette = self.ds + if message_type == "all": + datasette.add_message(request, message, datasette.INFO) + datasette.add_message(request, message, datasette.WARNING) + datasette.add_message(request, message, datasette.ERROR) + else: + datasette.add_message(request, message, getattr(datasette, message_type)) + return Response.redirect("/") diff --git a/docs/internals.rst b/docs/internals.rst index b3ad623f..4d51d614 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -214,6 +214,24 @@ This method returns a signed string, which can be decoded and verified using :re Returns the original, decoded object that was passed to :ref:`datasette_sign`. If the signature is not valid this raises a ``itsdangerous.BadSignature`` exception. +.. _datasette_add_message: + +.add_message(request, message, message_type=datasette.INFO) +----------------------------------------------------------- + +``request`` - Request + The current Request object + +``message`` - string + The message string + +``message_type`` - constant, optional + The message type - ``datasette.INFO``, ``datasette.WARNING`` or ``datasette.ERROR`` + +Datasette's flash messaging mechanism allows you to add a message that will be displayed to the user on the next page that they visit. Messages are persisted in a ``ds_messages`` cookie. This method adds a message to that cookie. + +You can try out these messages (including the different visual styling of the three message types) using the ``/-/messages`` debugging tool. + .. _internals_database: Database class diff --git a/docs/introspection.rst b/docs/introspection.rst index e5d08dbc..084ee144 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -166,3 +166,11 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti "username": "some-user" } } + + +.. _MessagesDebugView: + +/-/messages +----------- + +The debug tool at ``/-/messages`` can be used to set flash messages to try out that feature. See :ref:`datasette_add_message` for details of this feature. diff --git a/tests/fixtures.py b/tests/fixtures.py index b2cfd3d6..daff0168 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -29,6 +29,12 @@ class TestResponse: self.headers = headers self.body = body + @property + def cookies(self): + cookie = SimpleCookie() + cookie.load(self.headers.get("set-cookie") or "") + return {key: value.value for key, value in cookie.items()} + @property def json(self): return json.loads(self.text) diff --git a/tests/plugins/messages_output_renderer.py b/tests/plugins/messages_output_renderer.py new file mode 100644 index 00000000..6b52f801 --- /dev/null +++ b/tests/plugins/messages_output_renderer.py @@ -0,0 +1,21 @@ +from datasette import hookimpl + + +def render_message_debug(datasette, request): + if request.args.get("add_msg"): + msg_type = request.args.get("type", "INFO") + datasette.add_message( + request, request.args["add_msg"], getattr(datasette, msg_type) + ) + return {"body": "Hello from message debug"} + + +@hookimpl +def register_output_renderer(datasette): + return [ + { + "extension": "message", + "render": render_message_debug, + "can_render": lambda: False, + } + ] diff --git a/tests/test_api.py b/tests/test_api.py index d7e7c03f..a5c6f6a2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1262,6 +1262,7 @@ def test_plugins_json(app_client): expected = [ {"name": name, "static": False, "templates": False, "version": None} for name in ( + "messages_output_renderer.py", "my_plugin.py", "my_plugin_2.py", "register_output_renderer.py", diff --git a/tests/test_auth.py b/tests/test_auth.py index ddf328af..ac8d7abe 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -9,11 +9,7 @@ def test_auth_token(app_client): response = app_client.get(path, allow_redirects=False,) assert 302 == response.status assert "/" == response.headers["Location"] - set_cookie = response.headers["set-cookie"] - assert set_cookie.endswith("; Path=/") - assert set_cookie.startswith("ds_actor=") - cookie_value = set_cookie.split("ds_actor=")[1].split("; Path=/")[0] - assert {"id": "root"} == app_client.ds.unsign(cookie_value, "actor") + assert {"id": "root"} == app_client.ds.unsign(response.cookies["ds_actor"], "actor") # Check that a second with same token fails assert app_client.ds._root_token is None assert 403 == app_client.get(path, allow_redirects=False,).status diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 00000000..d17e015c --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,28 @@ +from .fixtures import app_client +import pytest + + +@pytest.mark.parametrize( + "qs,expected", + [ + ("add_msg=added-message", [["added-message", 1]]), + ("add_msg=added-warning&type=WARNING", [["added-warning", 2]]), + ("add_msg=added-error&type=ERROR", [["added-error", 3]]), + ], +) +def test_add_message_sets_cookie(app_client, qs, expected): + response = app_client.get("/fixtures.message?{}".format(qs)) + signed = response.cookies["ds_messages"] + decoded = app_client.ds.unsign(signed, "messages") + assert expected == decoded + + +def test_messages_are_displayed_and_cleared(app_client): + # First set the message cookie + set_msg_response = app_client.get("/fixtures.message?add_msg=xmessagex") + # Now access a page that displays messages + response = app_client.get("/", cookies=set_msg_response.cookies) + # Messages should be in that HTML + assert "xmessagex" in response.text + # Cookie should have been set that clears messages + assert "" == response.cookies["ds_messages"] From 9dd6d1ae6d47c8175bcbb7b157aeb9990dd60190 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Jun 2020 14:29:12 -0700 Subject: [PATCH 09/11] More consistent use of response.text/response.json in tests, closes #792 --- tests/test_api.py | 10 ++-------- tests/test_config_dir.py | 13 ++++++------- tests/test_csv.py | 2 +- tests/test_html.py | 4 +--- tests/test_plugins.py | 10 ++++------ 5 files changed, 14 insertions(+), 25 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index a5c6f6a2..7ed4cced 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1762,16 +1762,10 @@ def test_common_prefix_database_names(app_client_conflicting_database_names): # https://github.com/simonw/datasette/issues/597 assert ["fixtures", "foo", "foo-bar"] == [ d["name"] - for d in json.loads( - app_client_conflicting_database_names.get("/-/databases.json").body.decode( - "utf8" - ) - ) + for d in app_client_conflicting_database_names.get("/-/databases.json").json ] for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")): - data = json.loads( - app_client_conflicting_database_names.get(path).body.decode("utf8") - ) + data = app_client_conflicting_database_names.get(path).json assert db_name == data["database"] diff --git a/tests/test_config_dir.py b/tests/test_config_dir.py index 50e67f80..490b1f1d 100644 --- a/tests/test_config_dir.py +++ b/tests/test_config_dir.py @@ -84,21 +84,20 @@ def config_dir_client(tmp_path_factory): def test_metadata(config_dir_client): response = config_dir_client.get("/-/metadata.json") assert 200 == response.status - assert METADATA == json.loads(response.text) + assert METADATA == response.json def test_config(config_dir_client): response = config_dir_client.get("/-/config.json") assert 200 == response.status - config = json.loads(response.text) - assert 60 == config["default_cache_ttl"] - assert not config["allow_sql"] + assert 60 == response.json["default_cache_ttl"] + assert not response.json["allow_sql"] def test_plugins(config_dir_client): response = config_dir_client.get("/-/plugins.json") assert 200 == response.status - assert "hooray.py" in {p["name"] for p in json.loads(response.text)} + assert "hooray.py" in {p["name"] for p in response.json} def test_templates_and_plugin(config_dir_client): @@ -123,7 +122,7 @@ def test_static_directory_browsing_not_allowed(config_dir_client): def test_databases(config_dir_client): response = config_dir_client.get("/-/databases.json") assert 200 == response.status - databases = json.loads(response.text) + databases = response.json assert 2 == len(databases) databases.sort(key=lambda d: d["name"]) assert "demo" == databases[0]["name"] @@ -141,4 +140,4 @@ def test_metadata_yaml(tmp_path_factory, filename): client.ds = ds response = client.get("/-/metadata.json") assert 200 == response.status - assert {"title": "Title from metadata"} == json.loads(response.text) + assert {"title": "Title from metadata"} == response.json diff --git a/tests/test_csv.py b/tests/test_csv.py index 1030c2bb..42022726 100644 --- a/tests/test_csv.py +++ b/tests/test_csv.py @@ -101,7 +101,7 @@ def test_csv_with_non_ascii_characters(app_client): ) assert response.status == 200 assert "text/plain; charset=utf-8" == response.headers["content-type"] - assert "text,number\r\nšœš¢š­š¢šžš¬,1\r\nbob,2\r\n" == response.body.decode("utf8") + assert "text,number\r\nšœš¢š­š¢šžš¬,1\r\nbob,2\r\n" == response.text def test_max_csv_mb(app_client_csv_max_mb_one): diff --git a/tests/test_html.py b/tests/test_html.py index e602bf0e..2d2a141a 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -606,9 +606,7 @@ def test_row_html_simple_primary_key(app_client): def test_table_not_exists(app_client): - assert "Table not found: blah" in app_client.get("/fixtures/blah").body.decode( - "utf8" - ) + assert "Table not found: blah" in app_client.get("/fixtures/blah").text def test_table_html_no_primary_key(app_client): diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 7a3fb49a..f69e7fa7 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -218,7 +218,7 @@ def test_plugin_config_file(app_client): ) def test_plugins_extra_body_script(app_client, path, expected_extra_body_script): r = re.compile(r"") - json_data = r.search(app_client.get(path).body.decode("utf8")).group(1) + json_data = r.search(app_client.get(path).text).group(1) actual_data = json.loads(json_data) assert expected_extra_body_script == actual_data @@ -331,7 +331,7 @@ def view_names_client(tmp_path_factory): def test_view_names(view_names_client, path, view_name): response = view_names_client.get(path) assert response.status == 200 - assert "view_name:{}".format(view_name) == response.body.decode("utf8") + assert "view_name:{}".format(view_name) == response.text def test_register_output_renderer_no_parameters(app_client): @@ -345,8 +345,7 @@ def test_register_output_renderer_all_parameters(app_client): assert 200 == response.status # Lots of 'at 0x103a4a690' in here - replace those so we can do # an easy comparison - body = response.body.decode("utf-8") - body = at_memory_re.sub(" at 0xXXX", body) + body = at_memory_re.sub(" at 0xXXX", response.text) assert { "1+1": 2, "datasette": "", @@ -468,7 +467,6 @@ def test_register_facet_classes(app_client): response = app_client.get( "/fixtures/compound_three_primary_keys.json?_dummy_facet=1" ) - data = json.loads(response.body) assert [ { "name": "pk1", @@ -502,7 +500,7 @@ def test_register_facet_classes(app_client): "name": "pk3", "toggle_url": "http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_facet=pk3", }, - ] == data["suggested_facets"] + ] == response.json["suggested_facets"] def test_actor_from_request(app_client): From 69aa0277f561965ca3afb61bf6bec1a93761ffaa Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Jun 2020 14:49:28 -0700 Subject: [PATCH 10/11] /-/plugins now shows details of hooks, closes #794 Also added /-/plugins?all=1 parameter to see default plugins. --- datasette/app.py | 9 ++++--- datasette/plugins.py | 1 + docs/introspection.rst | 5 +++- tests/test_api.py | 61 ++++++++++++++++++++++++++++++++++++------ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/datasette/app.py b/datasette/app.py index 9f6b783b..633c4a29 100644 --- a/datasette/app.py +++ b/datasette/app.py @@ -626,9 +626,9 @@ class Datasette: }, } - def _plugins(self, show_all=False): + def _plugins(self, request): ps = list(get_plugins()) - if not show_all: + if not request.args.get("all"): ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS] return [ { @@ -636,6 +636,7 @@ class Datasette: "static": p["static_path"] is not None, "templates": p["templates_path"] is not None, "version": p.get("version"), + "hooks": p["hooks"], } for p in ps ] @@ -823,7 +824,9 @@ class Datasette: r"/-/versions(?P(\.json)?)$", ) add_route( - JsonDataView.as_asgi(self, "plugins.json", self._plugins), + JsonDataView.as_asgi( + self, "plugins.json", self._plugins, needs_request=True + ), r"/-/plugins(?P(\.json)?)$", ) add_route( diff --git a/datasette/plugins.py b/datasette/plugins.py index 26d4fd63..b35b750f 100644 --- a/datasette/plugins.py +++ b/datasette/plugins.py @@ -49,6 +49,7 @@ def get_plugins(): "name": plugin.__name__, "static_path": static_path, "templates_path": templates_path, + "hooks": [h.name for h in pm.get_hookcallers(plugin)], } distinfo = plugin_to_distinfo.get(plugin) if distinfo: diff --git a/docs/introspection.rst b/docs/introspection.rst index 084ee144..08006529 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -78,10 +78,13 @@ Shows a list of currently installed plugins and their versions. `Plugins example "name": "datasette_cluster_map", "static": true, "templates": false, - "version": "0.4" + "version": "0.10", + "hooks": ["extra_css_urls", "extra_js_urls", "extra_body_script"] } ] +Add ``?all=1`` to include details of the default plugins baked into Datasette. + .. _JsonDataView_config: /-/config diff --git a/tests/test_api.py b/tests/test_api.py index 7ed4cced..4b752f31 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1260,14 +1260,59 @@ def test_threads_json(app_client): def test_plugins_json(app_client): response = app_client.get("/-/plugins.json") expected = [ - {"name": name, "static": False, "templates": False, "version": None} - for name in ( - "messages_output_renderer.py", - "my_plugin.py", - "my_plugin_2.py", - "register_output_renderer.py", - "view_name.py", - ) + { + "name": "messages_output_renderer.py", + "static": False, + "templates": False, + "version": None, + "hooks": ["register_output_renderer"], + }, + { + "name": "my_plugin.py", + "static": False, + "templates": False, + "version": None, + "hooks": [ + "actor_from_request", + "extra_body_script", + "extra_css_urls", + "extra_js_urls", + "extra_template_vars", + "permission_allowed", + "prepare_connection", + "prepare_jinja2_environment", + "register_facet_classes", + "render_cell", + ], + }, + { + "name": "my_plugin_2.py", + "static": False, + "templates": False, + "version": None, + "hooks": [ + "actor_from_request", + "asgi_wrapper", + "extra_js_urls", + "extra_template_vars", + "permission_allowed", + "render_cell", + ], + }, + { + "name": "register_output_renderer.py", + "static": False, + "templates": False, + "version": None, + "hooks": ["register_output_renderer"], + }, + { + "name": "view_name.py", + "static": False, + "templates": False, + "version": None, + "hooks": ["extra_template_vars"], + }, ] assert expected == sorted(response.json, key=lambda p: p["name"]) From b76a90e8719e7caa8c181ef0b456f4c3af2bd2d2 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Tue, 2 Jun 2020 15:34:50 -0700 Subject: [PATCH 11/11] Added messages to pattern portfolio, refs #790 --- datasette/templates/patterns.html | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/datasette/templates/patterns.html b/datasette/templates/patterns.html index 9ea4ae42..73443ac2 100644 --- a/datasette/templates/patterns.html +++ b/datasette/templates/patterns.html @@ -20,6 +20,12 @@ attraction_characteristic

+

Messages

+
+

Example message

+

Example message

+

Example message

+

.bd for /

Datasette Fixtures