kopia lustrzana https://github.com/simonw/datasette
Flash messages mechanism, closes #790
rodzic
1d0bea157a
commit
4fa7cf6853
|
@ -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$",
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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:
|
||||
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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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("/")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 = [
|
||||
{"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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
Ładowanie…
Reference in New Issue