mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
Compare commits
12 commits
main
...
writable-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76a90e871 | ||
|
|
69aa0277f5 | ||
|
|
9dd6d1ae6d | ||
|
|
61e40e917e | ||
|
|
29b8e80f3c | ||
|
|
2c3f086e98 | ||
|
|
6690d6bf7f | ||
|
|
61131f1fd7 | ||
|
|
ee51e0b82c | ||
|
|
5b252a2348 | ||
|
|
95bb5181b4 | ||
|
|
18c5bd1805 |
24 changed files with 393 additions and 62 deletions
|
|
@ -5,14 +5,9 @@ from http.cookies import SimpleCookie
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def actor_from_request(datasette, request):
|
def actor_from_request(datasette, request):
|
||||||
cookies = SimpleCookie()
|
if "ds_actor" not in request.cookies:
|
||||||
cookies.load(
|
|
||||||
dict(request.scope.get("headers") or []).get(b"cookie", b"").decode("utf-8")
|
|
||||||
)
|
|
||||||
if "ds_actor" not in cookies:
|
|
||||||
return None
|
return None
|
||||||
ds_actor = cookies["ds_actor"].value
|
|
||||||
try:
|
try:
|
||||||
return datasette.unsign(ds_actor, "actor")
|
return datasette.unsign(request.cookies["ds_actor"], "actor")
|
||||||
except BadSignature:
|
except BadSignature:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -10,6 +11,7 @@ import sys
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
import traceback
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import uuid
|
||||||
from concurrent import futures
|
from concurrent import futures
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -30,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
|
||||||
|
|
@ -156,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,
|
||||||
|
|
@ -289,7 +297,7 @@ class Datasette:
|
||||||
pm.hook.prepare_jinja2_environment(env=self.jinja_env)
|
pm.hook.prepare_jinja2_environment(env=self.jinja_env)
|
||||||
|
|
||||||
self._register_renderers()
|
self._register_renderers()
|
||||||
self.permission_checks = collections.deque(maxlen=30)
|
self._permission_checks = collections.deque(maxlen=30)
|
||||||
self._root_token = os.urandom(32).hex()
|
self._root_token = os.urandom(32).hex()
|
||||||
|
|
||||||
def sign(self, value, namespace="default"):
|
def sign(self, value, namespace="default"):
|
||||||
|
|
@ -423,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
|
||||||
):
|
):
|
||||||
|
|
@ -445,7 +485,7 @@ class Datasette:
|
||||||
if result is None:
|
if result is None:
|
||||||
result = default
|
result = default
|
||||||
used_default = True
|
used_default = True
|
||||||
self.permission_checks.append(
|
self._permission_checks.append(
|
||||||
{
|
{
|
||||||
"when": datetime.datetime.utcnow().isoformat(),
|
"when": datetime.datetime.utcnow().isoformat(),
|
||||||
"actor": actor,
|
"actor": actor,
|
||||||
|
|
@ -586,9 +626,9 @@ class Datasette:
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def _plugins(self, show_all=False):
|
def _plugins(self, request):
|
||||||
ps = list(get_plugins())
|
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]
|
ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -596,6 +636,7 @@ class Datasette:
|
||||||
"static": p["static_path"] is not None,
|
"static": p["static_path"] is not None,
|
||||||
"templates": p["templates_path"] is not None,
|
"templates": p["templates_path"] is not None,
|
||||||
"version": p.get("version"),
|
"version": p.get("version"),
|
||||||
|
"hooks": p["hooks"],
|
||||||
}
|
}
|
||||||
for p in ps
|
for p in ps
|
||||||
]
|
]
|
||||||
|
|
@ -783,7 +824,9 @@ class Datasette:
|
||||||
r"/-/versions(?P<as_format>(\.json)?)$",
|
r"/-/versions(?P<as_format>(\.json)?)$",
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
JsonDataView.as_asgi(self, "plugins.json", self._plugins),
|
JsonDataView.as_asgi(
|
||||||
|
self, "plugins.json", self._plugins, needs_request=True
|
||||||
|
),
|
||||||
r"/-/plugins(?P<as_format>(\.json)?)$",
|
r"/-/plugins(?P<as_format>(\.json)?)$",
|
||||||
)
|
)
|
||||||
add_route(
|
add_route(
|
||||||
|
|
@ -808,6 +851,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$",
|
||||||
)
|
)
|
||||||
|
|
@ -856,13 +902,15 @@ class DatasetteRouter(AsgiRouter):
|
||||||
base_url = self.ds.config("base_url")
|
base_url = self.ds.config("base_url")
|
||||||
if base_url != "/" and path.startswith(base_url):
|
if base_url != "/" and path.startswith(base_url):
|
||||||
path = "/" + path[len(base_url) :]
|
path = "/" + path[len(base_url) :]
|
||||||
|
# Add a correlation ID
|
||||||
|
scope_modifications = {"correlation_id": str(uuid.uuid4())}
|
||||||
# Apply force_https_urls, if set
|
# Apply force_https_urls, if set
|
||||||
if (
|
if (
|
||||||
self.ds.config("force_https_urls")
|
self.ds.config("force_https_urls")
|
||||||
and scope["type"] == "http"
|
and scope["type"] == "http"
|
||||||
and scope.get("scheme") != "https"
|
and scope.get("scheme") != "https"
|
||||||
):
|
):
|
||||||
scope = dict(scope, scheme="https")
|
scope_modifications["scheme"] = "https"
|
||||||
# Handle authentication
|
# Handle authentication
|
||||||
actor = None
|
actor = None
|
||||||
for actor in pm.hook.actor_from_request(
|
for actor in pm.hook.actor_from_request(
|
||||||
|
|
@ -874,7 +922,10 @@ class DatasetteRouter(AsgiRouter):
|
||||||
actor = await actor
|
actor = await actor
|
||||||
if actor:
|
if actor:
|
||||||
break
|
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):
|
async def handle_404(self, scope, receive, send, exception=None):
|
||||||
# If URL has a trailing slash, redirect to URL without it
|
# If URL has a trailing slash, redirect to URL without it
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ def get_plugins():
|
||||||
"name": plugin.__name__,
|
"name": plugin.__name__,
|
||||||
"static_path": static_path,
|
"static_path": static_path,
|
||||||
"templates_path": templates_path,
|
"templates_path": templates_path,
|
||||||
|
"hooks": [h.name for h in pm.get_hookcallers(plugin)],
|
||||||
}
|
}
|
||||||
distinfo = plugin_to_distinfo.get(plugin)
|
distinfo = plugin_to_distinfo.get(plugin)
|
||||||
if distinfo:
|
if distinfo:
|
||||||
|
|
|
||||||
|
|
@ -351,3 +351,19 @@ p.zero-results {
|
||||||
.type-float, .type-int {
|
.type-float, .type-int {
|
||||||
color: #666;
|
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>
|
<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 %}
|
||||||
|
|
@ -20,6 +20,12 @@
|
||||||
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
|
<a href="/fixtures/attraction_characteristic">attraction_characteristic</a>
|
||||||
</p>
|
</p>
|
||||||
</nav>
|
</nav>
|
||||||
|
<h2 class="pattern-heading">Messages</h2>
|
||||||
|
<div class="bd">
|
||||||
|
<p class="message-info">Example message</p>
|
||||||
|
<p class="message-warning">Example message</p>
|
||||||
|
<p class="message-error">Example message</p>
|
||||||
|
</div>
|
||||||
<h2 class="pattern-heading">.bd for /</h2>
|
<h2 class="pattern-heading">.bd for /</h2>
|
||||||
<div class="bd">
|
<div class="bd">
|
||||||
<h1>Datasette Fixtures</h1>
|
<h1>Datasette Fixtures</h1>
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,12 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color(database) }}">{{ metadata.title or database }}</h1>
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
||||||
<form class="sql" action="{{ database_url(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="get">
|
<form class="sql" action="{{ database_url(database) }}{% if canned_query %}/{{ canned_query }}{% endif %}" method="{% if canned_write %}post{% else %}get{% endif %}">
|
||||||
<h3>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 %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
|
<h3>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 %} <span class="show-hide-sql">{% if hide_sql %}(<a href="{{ path_with_removed_args(request, {'_hide_sql': '1'}) }}">show</a>){% else %}(<a href="{{ path_with_added_args(request, {'_hide_sql': '1'}) }}">hide</a>){% endif %}</span></h3>
|
||||||
{% if not hide_sql %}
|
{% if not hide_sql %}
|
||||||
{% if editable and config.allow_sql %}
|
{% if editable and config.allow_sql %}
|
||||||
|
|
@ -74,7 +75,12 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="zero-results">0 results</p>
|
{% if success_message %}
|
||||||
|
<p class="success">{{ success_message }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if not canned_write %}
|
||||||
|
<p class="zero-results">0 results</p>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include "_codemirror_foot.html" %}
|
{% include "_codemirror_foot.html" %}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from mimetypes import guess_type
|
||||||
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from html import escape
|
from html import escape
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
import re
|
import re
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
|
||||||
|
|
@ -44,6 +45,12 @@ class Request:
|
||||||
def host(self):
|
def host(self):
|
||||||
return self.headers.get("host") or "localhost"
|
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
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
if self.scope.get("raw_path") is not None:
|
if self.scope.get("raw_path") is not None:
|
||||||
|
|
@ -173,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
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ class QueryView(DataView):
|
||||||
canned_query=None,
|
canned_query=None,
|
||||||
metadata=None,
|
metadata=None,
|
||||||
_size=None,
|
_size=None,
|
||||||
|
named_parameters=None,
|
||||||
|
write=False,
|
||||||
):
|
):
|
||||||
params = {key: request.args.get(key) for key in request.args}
|
params = {key: request.args.get(key) for key in request.args}
|
||||||
if "sql" in params:
|
if "sql" in params:
|
||||||
|
|
@ -113,7 +115,7 @@ class QueryView(DataView):
|
||||||
if "_shape" in params:
|
if "_shape" in params:
|
||||||
params.pop("_shape")
|
params.pop("_shape")
|
||||||
# Extract any :named parameters
|
# 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_values = {
|
||||||
named_parameter: params.get(named_parameter) or ""
|
named_parameter: params.get(named_parameter) or ""
|
||||||
for named_parameter in named_parameters
|
for named_parameter in named_parameters
|
||||||
|
|
@ -129,12 +131,49 @@ class QueryView(DataView):
|
||||||
extra_args["custom_time_limit"] = int(params["_timelimit"])
|
extra_args["custom_time_limit"] = int(params["_timelimit"])
|
||||||
if _size:
|
if _size:
|
||||||
extra_args["page_size"] = _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"]
|
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:
|
if canned_query:
|
||||||
templates.insert(
|
templates.insert(
|
||||||
0,
|
0,
|
||||||
|
|
|
||||||
|
|
@ -92,5 +92,29 @@ class PermissionsDebugView(BaseView):
|
||||||
return await self.render(
|
return await self.render(
|
||||||
["permissions_debug.html"],
|
["permissions_debug.html"],
|
||||||
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("/")
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,22 @@ class RowTableShared(DataView):
|
||||||
class TableView(RowTableShared):
|
class TableView(RowTableShared):
|
||||||
name = "table"
|
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(
|
async def data(
|
||||||
self,
|
self,
|
||||||
request,
|
request,
|
||||||
|
|
@ -241,6 +257,8 @@ class TableView(RowTableShared):
|
||||||
metadata=canned_query,
|
metadata=canned_query,
|
||||||
editable=False,
|
editable=False,
|
||||||
canned_query=table,
|
canned_query=table,
|
||||||
|
named_parameters=canned_query.get("params"),
|
||||||
|
write=bool(canned_query.get("write")),
|
||||||
)
|
)
|
||||||
|
|
||||||
db = self.ds.databases[database]
|
db = self.ds.databases[database]
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ The request object is passed to various plugin hooks. It represents an incoming
|
||||||
``.headers`` - dictionary (str -> str)
|
``.headers`` - dictionary (str -> str)
|
||||||
A dictionary of incoming HTTP request headers.
|
A dictionary of incoming HTTP request headers.
|
||||||
|
|
||||||
|
``.cookies`` - dictionary (str -> str)
|
||||||
|
A dictionary of incoming cookies
|
||||||
|
|
||||||
``.host`` - string
|
``.host`` - string
|
||||||
The host header from the incoming request, e.g. ``latest.datasette.io`` or ``localhost``.
|
The host header from the incoming request, e.g. ``latest.datasette.io`` or ``localhost``.
|
||||||
|
|
||||||
|
|
@ -211,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
|
||||||
|
|
|
||||||
|
|
@ -78,10 +78,13 @@ Shows a list of currently installed plugins and their versions. `Plugins example
|
||||||
"name": "datasette_cluster_map",
|
"name": "datasette_cluster_map",
|
||||||
"static": true,
|
"static": true,
|
||||||
"templates": false,
|
"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:
|
.. _JsonDataView_config:
|
||||||
|
|
||||||
/-/config
|
/-/config
|
||||||
|
|
@ -166,3 +169,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,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -1260,13 +1260,59 @@ def test_threads_json(app_client):
|
||||||
def test_plugins_json(app_client):
|
def test_plugins_json(app_client):
|
||||||
response = app_client.get("/-/plugins.json")
|
response = app_client.get("/-/plugins.json")
|
||||||
expected = [
|
expected = [
|
||||||
{"name": name, "static": False, "templates": False, "version": None}
|
{
|
||||||
for name in (
|
"name": "messages_output_renderer.py",
|
||||||
"my_plugin.py",
|
"static": False,
|
||||||
"my_plugin_2.py",
|
"templates": False,
|
||||||
"register_output_renderer.py",
|
"version": None,
|
||||||
"view_name.py",
|
"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"])
|
assert expected == sorted(response.json, key=lambda p: p["name"])
|
||||||
|
|
||||||
|
|
@ -1761,16 +1807,10 @@ def test_common_prefix_database_names(app_client_conflicting_database_names):
|
||||||
# https://github.com/simonw/datasette/issues/597
|
# https://github.com/simonw/datasette/issues/597
|
||||||
assert ["fixtures", "foo", "foo-bar"] == [
|
assert ["fixtures", "foo", "foo-bar"] == [
|
||||||
d["name"]
|
d["name"]
|
||||||
for d in json.loads(
|
for d in app_client_conflicting_database_names.get("/-/databases.json").json
|
||||||
app_client_conflicting_database_names.get("/-/databases.json").body.decode(
|
|
||||||
"utf8"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
]
|
||||||
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
|
for db_name, path in (("foo", "/foo.json"), ("foo-bar", "/foo-bar.json")):
|
||||||
data = json.loads(
|
data = app_client_conflicting_database_names.get(path).json
|
||||||
app_client_conflicting_database_names.get(path).body.decode("utf8")
|
|
||||||
)
|
|
||||||
assert db_name == data["database"]
|
assert db_name == data["database"]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -84,21 +84,20 @@ def config_dir_client(tmp_path_factory):
|
||||||
def test_metadata(config_dir_client):
|
def test_metadata(config_dir_client):
|
||||||
response = config_dir_client.get("/-/metadata.json")
|
response = config_dir_client.get("/-/metadata.json")
|
||||||
assert 200 == response.status
|
assert 200 == response.status
|
||||||
assert METADATA == json.loads(response.text)
|
assert METADATA == response.json
|
||||||
|
|
||||||
|
|
||||||
def test_config(config_dir_client):
|
def test_config(config_dir_client):
|
||||||
response = config_dir_client.get("/-/config.json")
|
response = config_dir_client.get("/-/config.json")
|
||||||
assert 200 == response.status
|
assert 200 == response.status
|
||||||
config = json.loads(response.text)
|
assert 60 == response.json["default_cache_ttl"]
|
||||||
assert 60 == config["default_cache_ttl"]
|
assert not response.json["allow_sql"]
|
||||||
assert not config["allow_sql"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_plugins(config_dir_client):
|
def test_plugins(config_dir_client):
|
||||||
response = config_dir_client.get("/-/plugins.json")
|
response = config_dir_client.get("/-/plugins.json")
|
||||||
assert 200 == response.status
|
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):
|
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):
|
def test_databases(config_dir_client):
|
||||||
response = config_dir_client.get("/-/databases.json")
|
response = config_dir_client.get("/-/databases.json")
|
||||||
assert 200 == response.status
|
assert 200 == response.status
|
||||||
databases = json.loads(response.text)
|
databases = response.json
|
||||||
assert 2 == len(databases)
|
assert 2 == len(databases)
|
||||||
databases.sort(key=lambda d: d["name"])
|
databases.sort(key=lambda d: d["name"])
|
||||||
assert "demo" == databases[0]["name"]
|
assert "demo" == databases[0]["name"]
|
||||||
|
|
@ -141,4 +140,4 @@ def test_metadata_yaml(tmp_path_factory, filename):
|
||||||
client.ds = ds
|
client.ds = ds
|
||||||
response = client.get("/-/metadata.json")
|
response = client.get("/-/metadata.json")
|
||||||
assert 200 == response.status
|
assert 200 == response.status
|
||||||
assert {"title": "Title from metadata"} == json.loads(response.text)
|
assert {"title": "Title from metadata"} == response.json
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ def test_csv_with_non_ascii_characters(app_client):
|
||||||
)
|
)
|
||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
assert "text/plain; charset=utf-8" == response.headers["content-type"]
|
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):
|
def test_max_csv_mb(app_client_csv_max_mb_one):
|
||||||
|
|
|
||||||
|
|
@ -606,9 +606,7 @@ def test_row_html_simple_primary_key(app_client):
|
||||||
|
|
||||||
|
|
||||||
def test_table_not_exists(app_client):
|
def test_table_not_exists(app_client):
|
||||||
assert "Table not found: blah" in app_client.get("/fixtures/blah").body.decode(
|
assert "Table not found: blah" in app_client.get("/fixtures/blah").text
|
||||||
"utf8"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_table_html_no_primary_key(app_client):
|
def test_table_html_no_primary_key(app_client):
|
||||||
|
|
|
||||||
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"]
|
||||||
|
|
@ -218,7 +218,7 @@ def test_plugin_config_file(app_client):
|
||||||
)
|
)
|
||||||
def test_plugins_extra_body_script(app_client, path, expected_extra_body_script):
|
def test_plugins_extra_body_script(app_client, path, expected_extra_body_script):
|
||||||
r = re.compile(r"<script>var extra_body_script = (.*?);</script>")
|
r = re.compile(r"<script>var extra_body_script = (.*?);</script>")
|
||||||
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)
|
actual_data = json.loads(json_data)
|
||||||
assert expected_extra_body_script == actual_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):
|
def test_view_names(view_names_client, path, view_name):
|
||||||
response = view_names_client.get(path)
|
response = view_names_client.get(path)
|
||||||
assert response.status == 200
|
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):
|
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
|
assert 200 == response.status
|
||||||
# Lots of 'at 0x103a4a690' in here - replace those so we can do
|
# Lots of 'at 0x103a4a690' in here - replace those so we can do
|
||||||
# an easy comparison
|
# an easy comparison
|
||||||
body = response.body.decode("utf-8")
|
body = at_memory_re.sub(" at 0xXXX", response.text)
|
||||||
body = at_memory_re.sub(" at 0xXXX", body)
|
|
||||||
assert {
|
assert {
|
||||||
"1+1": 2,
|
"1+1": 2,
|
||||||
"datasette": "<datasette.app.Datasette object at 0xXXX>",
|
"datasette": "<datasette.app.Datasette object at 0xXXX>",
|
||||||
|
|
@ -468,7 +467,6 @@ def test_register_facet_classes(app_client):
|
||||||
response = app_client.get(
|
response = app_client.get(
|
||||||
"/fixtures/compound_three_primary_keys.json?_dummy_facet=1"
|
"/fixtures/compound_three_primary_keys.json?_dummy_facet=1"
|
||||||
)
|
)
|
||||||
data = json.loads(response.body)
|
|
||||||
assert [
|
assert [
|
||||||
{
|
{
|
||||||
"name": "pk1",
|
"name": "pk1",
|
||||||
|
|
@ -502,7 +500,7 @@ def test_register_facet_classes(app_client):
|
||||||
"name": "pk3",
|
"name": "pk3",
|
||||||
"toggle_url": "http://localhost/fixtures/compound_three_primary_keys.json?_dummy_facet=1&_facet=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):
|
def test_actor_from_request(app_client):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue