mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Flash messages mechanism, closes #790
This commit is contained in:
parent
29b8e80f3c
commit
61e40e917e
14 changed files with 212 additions and 8 deletions
|
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
import collections
|
import collections
|
||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
@ -31,6 +32,7 @@ from .views.special import (
|
||||||
PatternPortfolioView,
|
PatternPortfolioView,
|
||||||
AuthTokenView,
|
AuthTokenView,
|
||||||
PermissionsDebugView,
|
PermissionsDebugView,
|
||||||
|
MessagesDebugView,
|
||||||
)
|
)
|
||||||
from .views.table import RowView, TableView
|
from .views.table import RowView, TableView
|
||||||
from .renderer import json_renderer
|
from .renderer import json_renderer
|
||||||
|
|
@ -157,6 +159,11 @@ async def favicon(scope, receive, send):
|
||||||
|
|
||||||
|
|
||||||
class Datasette:
|
class Datasette:
|
||||||
|
# Message constants:
|
||||||
|
INFO = 1
|
||||||
|
WARNING = 2
|
||||||
|
ERROR = 3
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
files,
|
files,
|
||||||
|
|
@ -424,6 +431,38 @@ class Datasette:
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
pm.hook.prepare_connection(conn=conn, database=database, datasette=self)
|
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(
|
async def permission_allowed(
|
||||||
self, actor, action, resource_type=None, resource_identifier=None, default=False
|
self, actor, action, resource_type=None, resource_identifier=None, default=False
|
||||||
):
|
):
|
||||||
|
|
@ -809,6 +848,9 @@ class Datasette:
|
||||||
add_route(
|
add_route(
|
||||||
PermissionsDebugView.as_asgi(self), r"/-/permissions$",
|
PermissionsDebugView.as_asgi(self), r"/-/permissions$",
|
||||||
)
|
)
|
||||||
|
add_route(
|
||||||
|
MessagesDebugView.as_asgi(self), r"/-/messages$",
|
||||||
|
)
|
||||||
add_route(
|
add_route(
|
||||||
PatternPortfolioView.as_asgi(self), r"/-/patterns$",
|
PatternPortfolioView.as_asgi(self), r"/-/patterns$",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -352,8 +352,18 @@ p.zero-results {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.success {
|
.message-info {
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
border: 1px solid green;
|
border: 1px solid green;
|
||||||
background-color: #c7fbc7;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,14 @@
|
||||||
<nav class="hd">{% block nav %}{% endblock %}</nav>
|
<nav class="hd">{% block nav %}{% endblock %}</nav>
|
||||||
|
|
||||||
<div class="bd">
|
<div class="bd">
|
||||||
|
{% block messages %}
|
||||||
|
{% if show_messages %}
|
||||||
|
{% for message, message_type in show_messages() %}
|
||||||
|
<p class="message-{% if message_type == 1 %}info{% elif message_type == 2 %}warning{% elif message_type == 3 %}error{% endif %}">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
26
datasette/templates/messages_debug.html
Normal file
26
datasette/templates/messages_debug.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Debug messages{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<h1>Debug messages</h1>
|
||||||
|
|
||||||
|
<p>Set a message:</p>
|
||||||
|
|
||||||
|
<form action="/-/messages" method="POST">
|
||||||
|
<div>
|
||||||
|
<input type="text" name="message" style="width: 40%">
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<select name="message_type">
|
||||||
|
<option>INFO</option>
|
||||||
|
<option>WARNING</option>
|
||||||
|
<option>ERROR</option>
|
||||||
|
<option>all</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Add message">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -180,9 +180,9 @@ class AsgiLifespan:
|
||||||
|
|
||||||
|
|
||||||
class AsgiView:
|
class AsgiView:
|
||||||
def dispatch_request(self, request, *args, **kwargs):
|
async def dispatch_request(self, request, *args, **kwargs):
|
||||||
handler = getattr(self, request.method.lower(), None)
|
handler = getattr(self, request.method.lower(), None)
|
||||||
return handler(request, *args, **kwargs)
|
return await handler(request, *args, **kwargs)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def as_asgi(cls, *class_args, **class_kwargs):
|
def as_asgi(cls, *class_args, **class_kwargs):
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
import itertools
|
import itertools
|
||||||
|
from itsdangerous import BadSignature
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
@ -73,6 +74,20 @@ class BaseView(AsgiView):
|
||||||
def database_color(self, database):
|
def database_color(self, database):
|
||||||
return "ff0000"
|
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):
|
async def render(self, templates, request, context=None):
|
||||||
context = context or {}
|
context = context or {}
|
||||||
template = self.ds.jinja_env.select_template(templates)
|
template = self.ds.jinja_env.select_template(templates)
|
||||||
|
|
@ -81,6 +96,7 @@ class BaseView(AsgiView):
|
||||||
**{
|
**{
|
||||||
"database_url": self.database_url,
|
"database_url": self.database_url,
|
||||||
"database_color": self.database_color,
|
"database_color": self.database_color,
|
||||||
|
"show_messages": lambda: self.ds._show_messages(request),
|
||||||
"select_templates": [
|
"select_templates": [
|
||||||
"{}{}".format(
|
"{}{}".format(
|
||||||
"*" if template_name == template.name else "", template_name
|
"*" if template_name == template.name else "", template_name
|
||||||
|
|
|
||||||
|
|
@ -94,3 +94,27 @@ class PermissionsDebugView(BaseView):
|
||||||
request,
|
request,
|
||||||
{"permission_checks": reversed(self.ds._permission_checks)},
|
{"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("/")
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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:
|
.. _internals_database:
|
||||||
|
|
||||||
Database class
|
Database class
|
||||||
|
|
|
||||||
|
|
@ -166,3 +166,11 @@ Shows the currently authenticated actor. Useful for debugging Datasette authenti
|
||||||
"username": "some-user"
|
"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.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,12 @@ class TestResponse:
|
||||||
self.headers = headers
|
self.headers = headers
|
||||||
self.body = body
|
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
|
@property
|
||||||
def json(self):
|
def json(self):
|
||||||
return json.loads(self.text)
|
return json.loads(self.text)
|
||||||
|
|
|
||||||
21
tests/plugins/messages_output_renderer.py
Normal file
21
tests/plugins/messages_output_renderer.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -1262,6 +1262,7 @@ def test_plugins_json(app_client):
|
||||||
expected = [
|
expected = [
|
||||||
{"name": name, "static": False, "templates": False, "version": None}
|
{"name": name, "static": False, "templates": False, "version": None}
|
||||||
for name in (
|
for name in (
|
||||||
|
"messages_output_renderer.py",
|
||||||
"my_plugin.py",
|
"my_plugin.py",
|
||||||
"my_plugin_2.py",
|
"my_plugin_2.py",
|
||||||
"register_output_renderer.py",
|
"register_output_renderer.py",
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,7 @@ def test_auth_token(app_client):
|
||||||
response = app_client.get(path, allow_redirects=False,)
|
response = app_client.get(path, allow_redirects=False,)
|
||||||
assert 302 == response.status
|
assert 302 == response.status
|
||||||
assert "/" == response.headers["Location"]
|
assert "/" == response.headers["Location"]
|
||||||
set_cookie = response.headers["set-cookie"]
|
assert {"id": "root"} == app_client.ds.unsign(response.cookies["ds_actor"], "actor")
|
||||||
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")
|
|
||||||
# Check that a second with same token fails
|
# Check that a second with same token fails
|
||||||
assert app_client.ds._root_token is None
|
assert app_client.ds._root_token is None
|
||||||
assert 403 == app_client.get(path, allow_redirects=False,).status
|
assert 403 == app_client.get(path, allow_redirects=False,).status
|
||||||
|
|
|
||||||
28
tests/test_messages.py
Normal file
28
tests/test_messages.py
Normal file
|
|
@ -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"]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue