Flash messages mechanism, closes #790

pull/796/head
Simon Willison 2020-06-02 14:08:12 -07:00
rodzic 1d0bea157a
commit 4fa7cf6853
14 zmienionych plików z 217 dodań i 7 usunięć

Wyświetl plik

@ -2,6 +2,7 @@ import asyncio
import collections
import datetime
import hashlib
from http.cookies import SimpleCookie
import itertools
import json
import os
@ -30,6 +31,7 @@ from .views.special import (
PatternPortfolioView,
AuthTokenView,
PermissionsDebugView,
MessagesDebugView,
)
from .views.table import RowView, TableView
from .renderer import json_renderer
@ -156,6 +158,11 @@ async def favicon(scope, receive, send):
class Datasette:
# Message constants:
INFO = 1
WARNING = 2
ERROR = 3
def __init__(
self,
files,
@ -423,6 +430,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
):
@ -808,6 +847,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$",
)

Wyświetl plik

@ -351,3 +351,19 @@ p.zero-results {
.type-float, .type-int {
color: #666;
}
.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;
}

Wyświetl plik

@ -17,6 +17,14 @@
<nav class="hd">{% block nav %}{% endblock %}</nav>
<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 %}
{% endblock %}
</div>

Wyświetl plik

@ -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 %}

Wyświetl plik

@ -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):

Wyświetl plik

@ -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

Wyświetl plik

@ -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("/")

Wyświetl plik

@ -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

Wyświetl plik

@ -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.

Wyświetl plik

@ -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)

Wyświetl plik

@ -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,
}
]

Wyświetl plik

@ -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",

Wyświetl plik

@ -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

Wyświetl plik

@ -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"]