Skip to content

Commit

Permalink
Flash messages mechanism, closes #790
Browse files Browse the repository at this point in the history
  • Loading branch information
simonw committed Jun 2, 2020
1 parent 1d0bea1 commit 4fa7cf6
Show file tree
Hide file tree
Showing 14 changed files with 217 additions and 7 deletions.
42 changes: 42 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import collections
import datetime
import hashlib
from http.cookies import SimpleCookie
import itertools
import json
import os
Expand Down Expand Up @@ -30,6 +31,7 @@
PatternPortfolioView,
AuthTokenView,
PermissionsDebugView,
MessagesDebugView,
)
from .views.table import RowView, TableView
from .renderer import json_renderer
Expand Down Expand Up @@ -156,6 +158,11 @@ async def favicon(scope, receive, send):


class Datasette:
# Message constants:
INFO = 1
WARNING = 2
ERROR = 3

def __init__(
self,
files,
Expand Down Expand Up @@ -423,6 +430,38 @@ def _prepare_connection(self, conn, database):
# 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
):
Expand Down Expand Up @@ -808,6 +847,9 @@ def add_route(view, regex):
add_route(
PermissionsDebugView.as_asgi(self), r"/-/permissions$",
)
add_route(
MessagesDebugView.as_asgi(self), r"/-/messages$",
)
add_route(
PatternPortfolioView.as_asgi(self), r"/-/patterns$",
)
Expand Down
16 changes: 16 additions & 0 deletions datasette/static/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 8 additions & 0 deletions datasette/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
26 changes: 26 additions & 0 deletions datasette/templates/messages_debug.html
Original file line number Diff line number Diff line change
@@ -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 %}
4 changes: 2 additions & 2 deletions datasette/utils/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ async def __call__(self, scope, receive, send):


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):
Expand Down
16 changes: 16 additions & 0 deletions datasette/views/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import csv
import itertools
from itsdangerous import BadSignature
import json
import re
import time
Expand Down Expand Up @@ -73,6 +74,20 @@ def database_url(self, database):
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)
Expand All @@ -81,6 +96,7 @@ async def render(self, templates, request, context=None):
**{
"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
Expand Down
24 changes: 24 additions & 0 deletions datasette/views/special.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,27 @@ async def get(self, request):
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("/")
18 changes: 18 additions & 0 deletions docs/internals.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/introspection.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ def __init__(self, status, headers, body):
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)
Expand Down
21 changes: 21 additions & 0 deletions tests/plugins/messages_output_renderer.py
Original file line number Diff line number Diff line change
@@ -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,
}
]
1 change: 1 addition & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 1 addition & 5 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/test_messages.py
Original file line number Diff line number Diff line change
@@ -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"]

0 comments on commit 4fa7cf6

Please sign in to comment.