mirror of
https://github.com/simonw/datasette.git
synced 2026-07-01 13:14:34 +02:00
Documentation for template context variables
Merge pull request #2803 Closes #1510, closes #2127, closes #1477, refs #2333
This commit is contained in:
commit
e0cdd38786
20 changed files with 1870 additions and 386 deletions
|
|
@ -35,6 +35,7 @@ from jinja2 import (
|
|||
ChoiceLoader,
|
||||
Environment,
|
||||
FileSystemLoader,
|
||||
pass_context,
|
||||
PrefixLoader,
|
||||
)
|
||||
from jinja2.environment import Template
|
||||
|
|
@ -330,6 +331,50 @@ def _to_string(value):
|
|||
return json.dumps(value, default=str)
|
||||
|
||||
|
||||
def _template_context_json_default(value):
|
||||
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
||||
return {
|
||||
field.name: getattr(value, field.name)
|
||||
for field in dataclasses.fields(value)
|
||||
}
|
||||
return repr(value)
|
||||
|
||||
|
||||
@pass_context
|
||||
def _legacy_template_csrftoken(context):
|
||||
request = context.get("request")
|
||||
if request and "csrftoken" in request.scope:
|
||||
return request.scope["csrftoken"]()
|
||||
return ""
|
||||
|
||||
|
||||
# Documentation for the variables Datasette.render_template() adds to the
|
||||
# context for every page. This is part of the documented template contract:
|
||||
# keys added in render_template() must be documented here - the contract
|
||||
# tests in tests/test_template_context.py enforce this, and the docs in
|
||||
# docs/template_context.rst are generated from it.
|
||||
TEMPLATE_BASE_CONTEXT = {
|
||||
"request": "The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.",
|
||||
"crumb_items": 'Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.',
|
||||
"urls": "Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.",
|
||||
"actor": "The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.",
|
||||
"menu_links": "Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.",
|
||||
"display_actor": "Function that accepts an actor dictionary and returns the display string used in the navigation menu.",
|
||||
"show_logout": "True if the logout link should be shown in the navigation menu",
|
||||
"app_css_hash": "Hash of Datasette's app.css contents, used for cache busting",
|
||||
"edit_tools_js_hash": "Hash of Datasette's edit-tools.js contents, used for cache busting",
|
||||
"table_js_hash": "Hash of Datasette's table.js contents, used for cache busting",
|
||||
"zip": "Python's ``zip()`` builtin, made available to template logic",
|
||||
"body_scripts": 'List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``<script type="module">``; otherwise Datasette wraps it in a regular ``<script>`` block.',
|
||||
"format_bytes": "Function that accepts a byte count integer and returns a human-readable string such as ``1.2 MB``.",
|
||||
"show_messages": "Function returning any messages set for the current user, clearing them in the process. Returns a list of ``(message, type)`` pairs, where ``type`` is one of Datasette's ``INFO``, ``WARNING`` or ``ERROR`` constants.",
|
||||
"extra_css_urls": "List of extra CSS stylesheets to include on the page. Each item is a dictionary with ``url`` and optional ``sri`` keys, from plugins and configuration.",
|
||||
"extra_js_urls": "List of extra JavaScript URLs to include on the page. Each item is a dictionary with ``url`` plus optional ``sri`` and ``module`` keys, from plugins and configuration.",
|
||||
"base_url": "The configured :ref:`setting_base_url` setting",
|
||||
"datasette_version": "The version of Datasette that is running",
|
||||
}
|
||||
|
||||
|
||||
class Datasette:
|
||||
# Message constants:
|
||||
INFO = 1
|
||||
|
|
@ -562,6 +607,7 @@ class Datasette:
|
|||
)
|
||||
environment.filters["escape_css_string"] = escape_css_string
|
||||
environment.filters["quote_plus"] = urllib.parse.quote_plus
|
||||
environment.globals["csrftoken"] = _legacy_template_csrftoken
|
||||
self._jinja_env = environment
|
||||
environment.filters["escape_sqlite"] = escape_sqlite
|
||||
environment.filters["to_css_class"] = to_css_class
|
||||
|
|
@ -2265,7 +2311,11 @@ class Datasette:
|
|||
templates = [templates]
|
||||
template = self.get_jinja_environment(request).select_template(templates)
|
||||
if dataclasses.is_dataclass(context):
|
||||
context = dataclasses.asdict(context)
|
||||
# Shallow conversion - asdict() would deep-copy values, which
|
||||
# is wasteful and fails on values like sqlite3.Row
|
||||
context = {
|
||||
f.name: getattr(context, f.name) for f in dataclasses.fields(context)
|
||||
}
|
||||
body_scripts = []
|
||||
# pylint: disable=no-member
|
||||
for extra_script in pm.hook.extra_body_script(
|
||||
|
|
@ -2315,6 +2365,8 @@ class Datasette:
|
|||
links.extend(extra_links)
|
||||
return links
|
||||
|
||||
# Keys added here must be documented in TEMPLATE_BASE_CONTEXT -
|
||||
# the contract tests fail otherwise
|
||||
template_context = {
|
||||
**context,
|
||||
**{
|
||||
|
|
@ -2341,18 +2393,19 @@ class Datasette:
|
|||
"extra_js_urls", template, context, request, view_name
|
||||
),
|
||||
"base_url": self.setting("base_url"),
|
||||
"csrftoken": (
|
||||
request.scope["csrftoken"]
|
||||
if request and "csrftoken" in request.scope
|
||||
else lambda: ""
|
||||
),
|
||||
"datasette_version": __version__,
|
||||
},
|
||||
**extra_template_vars,
|
||||
}
|
||||
if request and request.args.get("_context") and self.setting("template_debug"):
|
||||
return "<pre>{}</pre>".format(
|
||||
escape(json.dumps(template_context, default=repr, indent=4))
|
||||
escape(
|
||||
json.dumps(
|
||||
template_context,
|
||||
default=_template_context_json_default,
|
||||
indent=4,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return await template.render_async(template_context)
|
||||
|
|
|
|||
|
|
@ -81,6 +81,16 @@ class ExtraRegistry:
|
|||
def public_classes_for_scope(self, scope):
|
||||
return self.classes_for_scope(scope, include_internal=False)
|
||||
|
||||
def internal_classes_for_scope(self, scope):
|
||||
# Extras that are available to HTML templates but excluded from
|
||||
# JSON responses - plain Providers are dependency plumbing and
|
||||
# never surface as keys, so they are not included
|
||||
return [
|
||||
cls
|
||||
for cls in self.classes_for_scope(scope)
|
||||
if issubclass(cls, Extra) and not cls.public
|
||||
]
|
||||
|
||||
def _registry_for_scope(self, scope):
|
||||
registry = self._scope_registries.get(scope)
|
||||
if registry is None:
|
||||
|
|
|
|||
40
datasette/template_contexts.py
Normal file
40
datasette/template_contexts.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""
|
||||
Index of the documented template contexts for Datasette's core HTML pages.
|
||||
|
||||
This module deliberately contains no documentation strings of its own -
|
||||
the documentation lives next to the code it describes:
|
||||
|
||||
- Every page renders a Context dataclass defined in its view module
|
||||
(DatabaseContext, QueryContext in views/database.py, TableContext in
|
||||
views/table.py, RowContext in views/row.py). Fields added by view code
|
||||
carry ``help`` metadata; fields declared with from_extra() take their
|
||||
documentation from the description on the matching Extra class in
|
||||
views/table_extras.py.
|
||||
- The keys render_template() adds to every page are documented in
|
||||
TEMPLATE_BASE_CONTEXT in datasette/app.py, next to the code that adds
|
||||
them.
|
||||
|
||||
The contract tests in tests/test_template_context.py assert that the real
|
||||
rendered context for each page exactly matches what is documented, and
|
||||
docs/template_context_doc.py generates docs/template_context.rst from the
|
||||
same classes.
|
||||
"""
|
||||
|
||||
from datasette.app import TEMPLATE_BASE_CONTEXT
|
||||
from datasette.views.database import DatabaseContext, QueryContext
|
||||
from datasette.views.row import RowContext
|
||||
from datasette.views.table import TableContext
|
||||
|
||||
PAGES = {
|
||||
"database": DatabaseContext,
|
||||
"query": QueryContext,
|
||||
"table": TableContext,
|
||||
"row": RowContext,
|
||||
}
|
||||
|
||||
|
||||
def documented_context_keys(page_name):
|
||||
"Set of every documented key for the named page, including base context keys"
|
||||
return set(TEMPLATE_BASE_CONTEXT) | {
|
||||
f.name for f in PAGES[page_name].documented_fields()
|
||||
}
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
<div class="db-table">
|
||||
<h3><a href="{{ urls.table(database, table.name) }}">{{ table.name }}</a>{% if table.private %} 🔒{% endif %}{% if table.hidden %}<em> (hidden)</em>{% endif %}</h3>
|
||||
<p><em>{% for column in table.columns %}{{ column }}{% if not loop.last %}, {% endif %}{% endfor %}</em></p>
|
||||
<p>{% if table.count is none %}Many rows{% elif table.count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
<p>{% if table.count is none %}Many rows{% elif table.count_truncated %}>{{ "{:,}".format(table.count - 1) }} rows{% else %}{{ "{:,}".format(table.count) }} row{% if table.count == 1 %}{% else %}s{% endif %}{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ database }}: {{ table }}: {% if count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
{% block title %}{{ database }}: {{ table }}: {% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}{% if human_description_en %} {{ human_description_en }}{% endif %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{{- super() -}}
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
|
||||
{% if count or human_description_en %}
|
||||
<h3>
|
||||
{% if count == count_limit + 1 %}>{{ "{:,}".format(count_limit) }} rows
|
||||
{% if count_truncated %}>{{ "{:,}".format(count - 1) }} rows
|
||||
{% if allow_execute_sql and query.sql %} <a class="count-sql" style="font-size: 0.8em;" href="{{ urls.database_query(database, count_sql) }}">count all</a>{% endif %}
|
||||
{% elif count or count == 0 %}{{ "{:,}".format(count) }} row{% if count == 1 %}{% else %}s{% endif %}{% endif %}
|
||||
{% if human_description_en %}{{ human_description_en }}{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,89 @@
|
|||
from dataclasses import dataclass
|
||||
import dataclasses
|
||||
import types
|
||||
import typing
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextField:
|
||||
name: str
|
||||
type_name: str
|
||||
help: str
|
||||
from_extra: bool = False
|
||||
|
||||
|
||||
def _type_name(type_):
|
||||
if type_ is type(None):
|
||||
return "None"
|
||||
origin = typing.get_origin(type_)
|
||||
args = typing.get_args(type_)
|
||||
if origin in (typing.Union, types.UnionType):
|
||||
return " | ".join(_type_name(arg) for arg in args)
|
||||
if origin is not None:
|
||||
name = getattr(origin, "__name__", str(origin).removeprefix("typing."))
|
||||
return "{}[{}]".format(name, ", ".join(_type_name(arg) for arg in args))
|
||||
return getattr(type_, "__name__", str(type_).removeprefix("typing."))
|
||||
|
||||
|
||||
def from_extra():
|
||||
"""
|
||||
Declare a Context dataclass field whose value comes from a registered
|
||||
Extra of the same name - its documentation is the Extra description,
|
||||
so the doc string lives next to the resolve() code rather than being
|
||||
duplicated on the dataclass.
|
||||
"""
|
||||
return dataclasses.field(metadata={"from_extra": True})
|
||||
|
||||
|
||||
class Context:
|
||||
"Base class for all documented contexts"
|
||||
|
||||
# Set on subclasses whose from_extra() fields should be resolved
|
||||
# against the extras registry for this scope
|
||||
extras_scope = None
|
||||
|
||||
@classmethod
|
||||
def documented_fields(cls):
|
||||
"List of ContextField describing the documented fields of this context"
|
||||
documented = []
|
||||
for f in dataclasses.fields(cls):
|
||||
if f.name.startswith("_"):
|
||||
continue
|
||||
is_from_extra = bool(f.metadata.get("from_extra"))
|
||||
if is_from_extra:
|
||||
help_text = cls._extra_description(f.name)
|
||||
else:
|
||||
help_text = f.metadata.get("help", "")
|
||||
documented.append(
|
||||
ContextField(
|
||||
name=f.name,
|
||||
type_name=_type_name(f.type),
|
||||
help=help_text,
|
||||
from_extra=is_from_extra,
|
||||
)
|
||||
)
|
||||
return documented
|
||||
|
||||
@classmethod
|
||||
def _extra_description(cls, name):
|
||||
# Imported lazily - table_extras is not needed just to define
|
||||
# Context subclasses
|
||||
from datasette.views.table_extras import table_extra_registry
|
||||
|
||||
try:
|
||||
extra_class = table_extra_registry.classes_by_name[name]
|
||||
except KeyError:
|
||||
raise KeyError(
|
||||
"{}.{} is declared with from_extra() but there is no "
|
||||
"registered extra of that name".format(cls.__name__, name)
|
||||
)
|
||||
if cls.extras_scope is not None and not extra_class.available_for(
|
||||
cls.extras_scope
|
||||
):
|
||||
raise ValueError(
|
||||
"{}.{} is declared with from_extra() but the {} extra is "
|
||||
"not available for scope {}".format(
|
||||
cls.__name__, name, name, cls.extras_scope
|
||||
)
|
||||
)
|
||||
return extra_class.description or ""
|
||||
|
|
|
|||
|
|
@ -1,31 +1,19 @@
|
|||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
import urllib
|
||||
from markupsafe import escape
|
||||
|
||||
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.utils.asgi import Request
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
EscapeHtmlWriter,
|
||||
InvalidSql,
|
||||
LimitedWriter,
|
||||
call_with_supported_arguments,
|
||||
path_from_row_pks,
|
||||
path_with_added_args,
|
||||
path_with_removed_args,
|
||||
path_with_format,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.utils.asgi import (
|
||||
AsgiStream,
|
||||
NotFound,
|
||||
Response,
|
||||
BadRequest,
|
||||
)
|
||||
|
|
@ -192,227 +180,6 @@ class BaseView:
|
|||
return view
|
||||
|
||||
|
||||
class DataView(BaseView):
|
||||
name = ""
|
||||
|
||||
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
||||
if request.query_string and "?" not in path and forward_querystring:
|
||||
path = f"{path}?{request.query_string}"
|
||||
if remove_args:
|
||||
path = path_with_removed_args(request, remove_args, path=path)
|
||||
r = Response.redirect(path)
|
||||
r.headers["Link"] = f"<{path}>; rel=preload"
|
||||
if self.ds.cors:
|
||||
add_cors_headers(r.headers)
|
||||
return r
|
||||
|
||||
async def data(self, request):
|
||||
raise NotImplementedError
|
||||
|
||||
async def as_csv(self, request, database):
|
||||
return await stream_csv(self.ds, self.data, request, database)
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
database = db.name
|
||||
database_route = db.route
|
||||
|
||||
_format = request.url_vars["format"]
|
||||
data_kwargs = {}
|
||||
|
||||
if _format == "csv":
|
||||
return await self.as_csv(request, database_route)
|
||||
|
||||
if _format is None:
|
||||
# HTML views default to expanding all foreign key labels
|
||||
data_kwargs["default_labels"] = True
|
||||
|
||||
extra_template_data = {}
|
||||
start = time.perf_counter()
|
||||
status_code = None
|
||||
templates = []
|
||||
try:
|
||||
response_or_template_contexts = await self.data(request, **data_kwargs)
|
||||
if isinstance(response_or_template_contexts, Response):
|
||||
return response_or_template_contexts
|
||||
# If it has four items, it includes an HTTP status code
|
||||
if len(response_or_template_contexts) == 4:
|
||||
(
|
||||
data,
|
||||
extra_template_data,
|
||||
templates,
|
||||
status_code,
|
||||
) = response_or_template_contexts
|
||||
else:
|
||||
data, extra_template_data, templates = response_or_template_contexts
|
||||
except QueryInterrupted as ex:
|
||||
raise DatasetteError(
|
||||
textwrap.dedent("""
|
||||
<p>SQL query took too long. The time limit is controlled by the
|
||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||
configuration option.</p>
|
||||
<textarea style="width: 90%">{}</textarea>
|
||||
<script>
|
||||
let ta = document.querySelector("textarea");
|
||||
ta.style.height = ta.scrollHeight + "px";
|
||||
</script>
|
||||
""".format(escape(ex.sql))).strip(),
|
||||
title="SQL Interrupted",
|
||||
status=400,
|
||||
message_is_html=True,
|
||||
)
|
||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||
|
||||
except sqlite3.OperationalError as e:
|
||||
raise DatasetteError(str(e))
|
||||
|
||||
except DatasetteError:
|
||||
raise
|
||||
|
||||
end = time.perf_counter()
|
||||
data["query_ms"] = (end - start) * 1000
|
||||
|
||||
# Special case for .jsono extension - redirect to _shape=objects
|
||||
if _format == "jsono":
|
||||
return self.redirect(
|
||||
request,
|
||||
path_with_added_args(
|
||||
request,
|
||||
{"_shape": "objects"},
|
||||
path=request.path.rsplit(".jsono", 1)[0] + ".json",
|
||||
),
|
||||
forward_querystring=False,
|
||||
)
|
||||
|
||||
if _format in self.ds.renderers.keys():
|
||||
# Dispatch request to the correct output format renderer
|
||||
# (CSV is not handled here due to streaming)
|
||||
result = call_with_supported_arguments(
|
||||
self.ds.renderers[_format][0],
|
||||
datasette=self.ds,
|
||||
columns=data.get("columns") or [],
|
||||
rows=data.get("rows") or [],
|
||||
sql=data.get("query", {}).get("sql", None),
|
||||
query_name=data.get("query_name"),
|
||||
database=database,
|
||||
table=data.get("table"),
|
||||
request=request,
|
||||
view_name=self.name,
|
||||
truncated=False, # TODO: support this
|
||||
error=data.get("error"),
|
||||
# These will be deprecated in Datasette 1.0:
|
||||
args=request.args,
|
||||
data=data,
|
||||
)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
if result is None:
|
||||
raise NotFound("No data")
|
||||
if isinstance(result, dict):
|
||||
r = Response(
|
||||
body=result.get("body"),
|
||||
status=result.get("status_code", status_code or 200),
|
||||
content_type=result.get("content_type", "text/plain"),
|
||||
headers=result.get("headers"),
|
||||
)
|
||||
elif isinstance(result, Response):
|
||||
r = result
|
||||
if status_code is not None:
|
||||
# Over-ride the status code
|
||||
r.status = status_code
|
||||
else:
|
||||
assert False, f"{result} should be dict or Response"
|
||||
else:
|
||||
extras = {}
|
||||
if callable(extra_template_data):
|
||||
extras = extra_template_data()
|
||||
if asyncio.iscoroutine(extras):
|
||||
extras = await extras
|
||||
else:
|
||||
extras = extra_template_data
|
||||
url_labels_extra = {}
|
||||
if data.get("expandable_columns"):
|
||||
url_labels_extra = {"_labels": "on"}
|
||||
|
||||
renderers = {}
|
||||
for key, (_, can_render) in self.ds.renderers.items():
|
||||
it_can_render = call_with_supported_arguments(
|
||||
can_render,
|
||||
datasette=self.ds,
|
||||
columns=data.get("columns") or [],
|
||||
rows=data.get("rows") or [],
|
||||
sql=data.get("query", {}).get("sql", None),
|
||||
query_name=data.get("query_name"),
|
||||
database=database,
|
||||
table=data.get("table"),
|
||||
request=request,
|
||||
view_name=self.name,
|
||||
)
|
||||
it_can_render = await await_me_maybe(it_can_render)
|
||||
if it_can_render:
|
||||
renderers[key] = self.ds.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format=key,
|
||||
extra_qs={**url_labels_extra},
|
||||
)
|
||||
)
|
||||
|
||||
url_csv_args = {"_size": "max", **url_labels_extra}
|
||||
url_csv = self.ds.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format="csv",
|
||||
extra_qs=url_csv_args,
|
||||
)
|
||||
)
|
||||
url_csv_path = url_csv.split("?")[0]
|
||||
context = {
|
||||
**data,
|
||||
**extras,
|
||||
**{
|
||||
"renderers": renderers,
|
||||
"url_csv": url_csv,
|
||||
"url_csv_path": url_csv_path,
|
||||
"url_csv_hidden_args": [
|
||||
(key, value)
|
||||
for key, value in urllib.parse.parse_qsl(request.query_string)
|
||||
if key not in ("_labels", "_facet", "_size")
|
||||
]
|
||||
+ [("_size", "max")],
|
||||
"settings": self.ds.settings_dict(),
|
||||
},
|
||||
}
|
||||
if "metadata" not in context:
|
||||
context["metadata"] = await self.ds.get_instance_metadata()
|
||||
r = await self.render(templates, request=request, context=context)
|
||||
if status_code is not None:
|
||||
r.status = status_code
|
||||
|
||||
ttl = request.args.get("_ttl", None)
|
||||
if ttl is None or not ttl.isdigit():
|
||||
ttl = self.ds.setting("default_cache_ttl")
|
||||
|
||||
return self.set_response_headers(r, ttl)
|
||||
|
||||
def set_response_headers(self, response, ttl):
|
||||
# Set far-future cache expiry
|
||||
if self.ds.cache_headers and response.status == 200:
|
||||
ttl = int(ttl)
|
||||
if ttl == 0:
|
||||
ttl_header = "no-cache"
|
||||
else:
|
||||
ttl_header = f"max-age={ttl}"
|
||||
response.headers["Cache-Control"] = ttl_header
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
if self.ds.cors:
|
||||
add_cors_headers(response.headers)
|
||||
return response
|
||||
|
||||
|
||||
def _error(messages, status=400):
|
||||
return Response.json({"ok": False, "errors": messages}, status=status)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
import asyncio
|
||||
import hashlib
|
||||
|
|
@ -11,7 +11,7 @@ import textwrap
|
|||
from datasette.extras import extra_names_from_request
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.resources import DatabaseResource, QueryResource
|
||||
from datasette.stored_queries import stored_query_to_dict
|
||||
from datasette.stored_queries import StoredQuery, stored_query_to_dict
|
||||
from datasette.write_sql import QueryWriteRejected
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
|
|
@ -45,6 +45,29 @@ from .table_create_alter import _create_table_ui_context
|
|||
from . import Context
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseTable:
|
||||
"Summary of a table or view shown on database and query pages."
|
||||
|
||||
name: str
|
||||
columns: list[str]
|
||||
primary_keys: list[str]
|
||||
count: int | None
|
||||
count_truncated: bool
|
||||
hidden: bool
|
||||
fts_table: str | None
|
||||
foreign_keys: dict[str, list[dict[str, str]]]
|
||||
private: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class DatabaseViewInfo:
|
||||
"Summary of a SQLite view shown on the database page."
|
||||
|
||||
name: str
|
||||
private: bool
|
||||
|
||||
|
||||
class DatabaseView(View):
|
||||
async def get(self, request, datasette):
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
|
|
@ -94,7 +117,7 @@ class DatabaseView(View):
|
|||
# Filter to just views
|
||||
view_names_set = set(await db.view_names())
|
||||
sql_views = [
|
||||
{"name": name, "private": allowed_dict[name].private}
|
||||
DatabaseViewInfo(name=name, private=allowed_dict[name].private)
|
||||
for name in allowed_dict
|
||||
if name in view_names_set
|
||||
]
|
||||
|
|
@ -169,9 +192,9 @@ class DatabaseView(View):
|
|||
"private": private,
|
||||
"path": datasette.urls.database(database),
|
||||
"size": db.size,
|
||||
"tables": tables,
|
||||
"hidden_count": len([t for t in tables if t["hidden"]]),
|
||||
"views": sql_views,
|
||||
"tables": [asdict(table) for table in tables],
|
||||
"hidden_count": len([table for table in tables if table.hidden]),
|
||||
"views": [asdict(view) for view in sql_views],
|
||||
"queries": [stored_query_to_dict(query) for query in stored_queries],
|
||||
"queries_more": queries_more,
|
||||
"queries_count": queries_count,
|
||||
|
|
@ -211,7 +234,7 @@ class DatabaseView(View):
|
|||
path=datasette.urls.database(database),
|
||||
size=db.size,
|
||||
tables=tables,
|
||||
hidden_count=len([t for t in tables if t["hidden"]]),
|
||||
hidden_count=len([table for table in tables if table.hidden]),
|
||||
views=sql_views,
|
||||
queries=stored_queries,
|
||||
queries_more=queries_more,
|
||||
|
|
@ -230,7 +253,6 @@ class DatabaseView(View):
|
|||
database_actions=database_actions,
|
||||
show_hidden=request.args.get("_show_hidden"),
|
||||
editable=True,
|
||||
count_limit=db.count_limit,
|
||||
allow_download=datasette.setting("allow_download")
|
||||
and not db.is_mutable
|
||||
and not db.is_memory,
|
||||
|
|
@ -257,16 +279,32 @@ class DatabaseView(View):
|
|||
|
||||
@dataclass
|
||||
class DatabaseContext(Context):
|
||||
"The page listing the tables, views and queries in a database, e.g. /fixtures."
|
||||
|
||||
documented_template = "database.html"
|
||||
|
||||
database: str = field(metadata={"help": "The name of the database"})
|
||||
private: bool = field(
|
||||
metadata={"help": "Boolean indicating if this is a private database"}
|
||||
)
|
||||
path: str = field(metadata={"help": "The URL path to this database"})
|
||||
size: int = field(metadata={"help": "The size of the database in bytes"})
|
||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||
tables: list[DatabaseTable] = field(
|
||||
metadata={
|
||||
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
}
|
||||
)
|
||||
hidden_count: int = field(metadata={"help": "Count of hidden tables"})
|
||||
views: list = field(metadata={"help": "List of view objects in the database"})
|
||||
queries: list = field(metadata={"help": "List of stored query objects"})
|
||||
views: list[DatabaseViewInfo] = field(
|
||||
metadata={
|
||||
"help": "List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes."
|
||||
}
|
||||
)
|
||||
queries: list[StoredQuery] = field(
|
||||
metadata={
|
||||
"help": "List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``."
|
||||
}
|
||||
)
|
||||
queries_more: bool = field(
|
||||
metadata={"help": "Boolean indicating if more stored queries are available"}
|
||||
)
|
||||
|
|
@ -275,48 +313,65 @@ class DatabaseContext(Context):
|
|||
metadata={"help": "Boolean indicating if custom SQL can be executed"}
|
||||
)
|
||||
table_columns: dict = field(
|
||||
metadata={"help": "Dictionary mapping table names to their column lists"}
|
||||
metadata={
|
||||
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
||||
}
|
||||
)
|
||||
metadata: dict = field(
|
||||
metadata={
|
||||
"help": "Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata."
|
||||
}
|
||||
)
|
||||
metadata: dict = field(metadata={"help": "Metadata for the database"})
|
||||
database_color: str = field(metadata={"help": "The color assigned to the database"})
|
||||
database_page_data: dict = field(
|
||||
metadata={"help": "JSON data used by JavaScript on the database page"}
|
||||
metadata={
|
||||
"help": 'JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.'
|
||||
}
|
||||
)
|
||||
database_actions: callable = field(
|
||||
metadata={
|
||||
"help": "Callable returning list of action links for the database menu"
|
||||
"help": 'Async callable returning action items for the database menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_database_actions`.'
|
||||
}
|
||||
)
|
||||
show_hidden: str = field(metadata={"help": "Value of _show_hidden query parameter"})
|
||||
editable: bool = field(
|
||||
metadata={"help": "Boolean indicating if the database is editable"}
|
||||
)
|
||||
count_limit: int = field(metadata={"help": "The maximum number of rows to count"})
|
||||
allow_download: bool = field(
|
||||
metadata={"help": "Boolean indicating if database download is allowed"}
|
||||
)
|
||||
attached_databases: list = field(
|
||||
metadata={"help": "List of names of attached databases"}
|
||||
metadata={
|
||||
"help": "List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`."
|
||||
}
|
||||
)
|
||||
alternate_url_json: str = field(
|
||||
metadata={"help": "URL for the alternate JSON version of this page"}
|
||||
)
|
||||
select_templates: list = field(
|
||||
metadata={
|
||||
"help": "List of templates that were considered for rendering this page"
|
||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||
}
|
||||
)
|
||||
top_database: callable = field(
|
||||
metadata={"help": "Callable to render the top_database slot"}
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_database`` plugin slot for this database and returns HTML."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QueryContext(Context):
|
||||
"The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name)."
|
||||
|
||||
documented_template = "query.html"
|
||||
|
||||
database: str = field(metadata={"help": "The name of the database being queried"})
|
||||
database_color: str = field(metadata={"help": "The color of the database"})
|
||||
query: dict = field(
|
||||
metadata={"help": "The SQL query object containing the `sql` string"}
|
||||
metadata={
|
||||
"help": "Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys."
|
||||
}
|
||||
)
|
||||
stored_query: str = field(
|
||||
metadata={"help": "The name of the stored query if this is a stored query"}
|
||||
|
|
@ -333,7 +388,9 @@ class QueryContext(Context):
|
|||
}
|
||||
)
|
||||
metadata: dict = field(
|
||||
metadata={"help": "Metadata about the database or the stored query"}
|
||||
metadata={
|
||||
"help": "Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``."
|
||||
}
|
||||
)
|
||||
db_is_immutable: bool = field(
|
||||
metadata={"help": "Boolean indicating if this database is immutable"}
|
||||
|
|
@ -357,22 +414,44 @@ class QueryContext(Context):
|
|||
save_query_url: str = field(
|
||||
metadata={"help": "URL to save the current arbitrary SQL as a query"}
|
||||
)
|
||||
tables: list = field(metadata={"help": "List of table objects in the database"})
|
||||
tables: list[DatabaseTable] = field(
|
||||
metadata={
|
||||
"help": "List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total."
|
||||
}
|
||||
)
|
||||
named_parameter_values: dict = field(
|
||||
metadata={"help": "Dictionary of parameter names/values"}
|
||||
metadata={
|
||||
"help": "Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``."
|
||||
}
|
||||
)
|
||||
edit_sql_url: str = field(
|
||||
metadata={"help": "URL to edit the SQL for a stored query"}
|
||||
)
|
||||
display_rows: list = field(metadata={"help": "List of result rows to display"})
|
||||
columns: list = field(metadata={"help": "List of column names"})
|
||||
renderers: dict = field(metadata={"help": "Dictionary of renderer name to URL"})
|
||||
display_rows: list = field(
|
||||
metadata={
|
||||
"help": "List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``."
|
||||
}
|
||||
)
|
||||
columns: list = field(
|
||||
metadata={
|
||||
"help": "List of result column names in the order they appear in ``display_rows`` and ``rows``."
|
||||
}
|
||||
)
|
||||
renderers: dict = field(
|
||||
metadata={
|
||||
"help": "Dictionary mapping output format names such as ``json`` to URLs for this query in that format."
|
||||
}
|
||||
)
|
||||
url_csv: str = field(metadata={"help": "URL for CSV export"})
|
||||
show_hide_hidden: str = field(
|
||||
metadata={"help": "Hidden input field for the _show_sql parameter"}
|
||||
metadata={
|
||||
"help": "Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state."
|
||||
}
|
||||
)
|
||||
table_columns: dict = field(
|
||||
metadata={"help": "Dictionary of table name to list of column names"}
|
||||
metadata={
|
||||
"help": "Dictionary mapping table names to lists of column names, used to power SQL autocomplete."
|
||||
}
|
||||
)
|
||||
alternate_url_json: str = field(
|
||||
metadata={"help": "URL for alternate JSON version of this page"}
|
||||
|
|
@ -380,23 +459,27 @@ class QueryContext(Context):
|
|||
# TODO: refactor this to somewhere else, probably ds.render_template()
|
||||
select_templates: list = field(
|
||||
metadata={
|
||||
"help": "List of templates that were considered for rendering this page"
|
||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||
}
|
||||
)
|
||||
top_query: callable = field(
|
||||
metadata={"help": "Callable to render the top_query slot"}
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_query`` plugin slot for this query and returns HTML."
|
||||
}
|
||||
)
|
||||
top_stored_query: callable = field(
|
||||
metadata={"help": "Callable to render the top_stored_query slot"}
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML."
|
||||
}
|
||||
)
|
||||
query_actions: callable = field(
|
||||
metadata={
|
||||
"help": "Callable returning a list of links for the query action menu"
|
||||
"help": 'Async callable returning action items for the query menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_query_actions`.'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def get_tables(datasette, request, db, allowed_dict):
|
||||
async def get_tables(datasette, request, db, allowed_dict) -> list[DatabaseTable]:
|
||||
"""
|
||||
Get list of tables with metadata for the database view.
|
||||
|
||||
|
|
@ -417,21 +500,36 @@ async def get_tables(datasette, request, db, allowed_dict):
|
|||
|
||||
table_columns = await db.table_columns(table)
|
||||
tables.append(
|
||||
{
|
||||
"name": table,
|
||||
"columns": table_columns,
|
||||
"primary_keys": await db.primary_keys(table),
|
||||
"count": table_counts[table],
|
||||
"hidden": table in hidden_table_names,
|
||||
"fts_table": await db.fts_table(table),
|
||||
"foreign_keys": all_foreign_keys[table],
|
||||
"private": allowed_dict[table].private,
|
||||
}
|
||||
DatabaseTable(
|
||||
name=table,
|
||||
columns=table_columns,
|
||||
primary_keys=await db.primary_keys(table),
|
||||
count=table_counts[table],
|
||||
count_truncated=_table_count_truncated(
|
||||
datasette, db, table, table_counts[table]
|
||||
),
|
||||
hidden=table in hidden_table_names,
|
||||
fts_table=await db.fts_table(table),
|
||||
foreign_keys=all_foreign_keys[table],
|
||||
private=allowed_dict[table].private,
|
||||
)
|
||||
)
|
||||
tables.sort(key=lambda t: (t["hidden"], t["name"]))
|
||||
tables.sort(key=lambda table: (table.hidden, table.name))
|
||||
return tables
|
||||
|
||||
|
||||
def _table_count_truncated(datasette, db, table, count):
|
||||
if count != db.count_limit + 1:
|
||||
return False
|
||||
if not db.is_mutable and datasette.inspect_data:
|
||||
try:
|
||||
datasette.inspect_data[db.name]["tables"][table]["count"]
|
||||
return False
|
||||
except KeyError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
async def database_download(request, datasette):
|
||||
from datasette.resources import DatabaseResource
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,36 @@
|
|||
import asyncio
|
||||
import json
|
||||
import textwrap
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import markupsafe
|
||||
import sqlite_utils
|
||||
|
||||
from datasette.utils.asgi import NotFound, Forbidden, Response
|
||||
from datasette.database import QueryInterrupted
|
||||
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
||||
from datasette.resources import TableResource
|
||||
from .base import DataView, BaseView, _error
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
from datasette.utils import (
|
||||
add_cors_headers,
|
||||
await_me_maybe,
|
||||
call_with_supported_arguments,
|
||||
CustomRow,
|
||||
InvalidSql,
|
||||
make_slot_function,
|
||||
path_from_row_pks,
|
||||
path_with_added_args,
|
||||
path_with_format,
|
||||
path_with_removed_args,
|
||||
to_css_class,
|
||||
escape_sqlite,
|
||||
sqlite3,
|
||||
)
|
||||
from datasette.plugins import pm
|
||||
import json
|
||||
import markupsafe
|
||||
import sqlite_utils
|
||||
from datasette.extras import extra_names_from_request
|
||||
from datasette.extras import extra_names_from_request, ExtraScope
|
||||
from . import Context, from_extra
|
||||
from .table import (
|
||||
display_columns_and_rows,
|
||||
_table_page_data,
|
||||
|
|
@ -24,9 +39,366 @@ from .table import (
|
|||
from .table_extras import RowExtraContext, resolve_row_extras, table_extra_registry
|
||||
|
||||
|
||||
class RowView(DataView):
|
||||
@dataclass
|
||||
class RowContext(Context):
|
||||
"The page showing an individual row, e.g. /fixtures/facetable/1."
|
||||
|
||||
documented_template = "row.html"
|
||||
extras_scope = ExtraScope.ROW
|
||||
|
||||
# Fields resolved by registered extras - their documentation comes
|
||||
# from the description on each Extra class in table_extras.py
|
||||
columns: list = from_extra()
|
||||
database: str = from_extra()
|
||||
database_color: str = from_extra()
|
||||
foreign_key_tables: list = from_extra()
|
||||
metadata: dict = from_extra()
|
||||
primary_keys: list = from_extra()
|
||||
private: bool = from_extra()
|
||||
table: str = from_extra()
|
||||
|
||||
# Fields added by the view code
|
||||
ok: bool = field(
|
||||
metadata={"help": "True if the data for this page was retrieved without errors"}
|
||||
)
|
||||
rows: list = field(
|
||||
metadata={
|
||||
"help": "A single-item list containing this row as a dictionary mapping column name to raw value."
|
||||
}
|
||||
)
|
||||
primary_key_values: list = field(
|
||||
metadata={"help": "Values of the primary keys for this row, from the URL"}
|
||||
)
|
||||
query_ms: float = field(
|
||||
metadata={
|
||||
"help": "Time taken by the SQL queries for this page, in milliseconds"
|
||||
}
|
||||
)
|
||||
display_columns: list = field(
|
||||
metadata={
|
||||
"help": "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
|
||||
}
|
||||
)
|
||||
display_rows: list = field(
|
||||
metadata={
|
||||
"help": "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys."
|
||||
}
|
||||
)
|
||||
custom_table_templates: list = field(
|
||||
metadata={
|
||||
"help": "Custom template names that were considered for displaying this row's table, in lookup order."
|
||||
}
|
||||
)
|
||||
row_actions: list = field(
|
||||
metadata={
|
||||
"help": 'Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.'
|
||||
}
|
||||
)
|
||||
row_mutation_ui: bool = field(
|
||||
metadata={"help": "True if the row edit/delete JavaScript UI should be enabled"}
|
||||
)
|
||||
table_page_data: dict = field(
|
||||
metadata={
|
||||
"help": "JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs."
|
||||
}
|
||||
)
|
||||
top_row: callable = field(
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_row`` plugin slot for this row and returns HTML."
|
||||
}
|
||||
)
|
||||
renderers: dict = field(
|
||||
metadata={
|
||||
"help": "Dictionary mapping output format names such as ``json`` to URLs for this row in that format."
|
||||
}
|
||||
)
|
||||
url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
|
||||
url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
|
||||
url_csv_hidden_args: list = field(
|
||||
metadata={
|
||||
"help": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``."
|
||||
}
|
||||
)
|
||||
settings: dict = field(
|
||||
metadata={
|
||||
"help": "Dictionary of Datasette's current settings, keyed by setting name."
|
||||
}
|
||||
)
|
||||
select_templates: list = field(
|
||||
metadata={
|
||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||
}
|
||||
)
|
||||
alternate_url_json: str = field(
|
||||
metadata={"help": "URL for the JSON version of this page"}
|
||||
)
|
||||
|
||||
|
||||
class RowView(BaseView):
|
||||
name = "row"
|
||||
|
||||
def redirect(self, request, path, forward_querystring=True, remove_args=None):
|
||||
if request.query_string and "?" not in path and forward_querystring:
|
||||
path = f"{path}?{request.query_string}"
|
||||
if remove_args:
|
||||
path = path_with_removed_args(request, remove_args, path=path)
|
||||
response = Response.redirect(path)
|
||||
response.headers["Link"] = f"<{path}>; rel=preload"
|
||||
if self.ds.cors:
|
||||
add_cors_headers(response.headers)
|
||||
return response
|
||||
|
||||
async def as_csv(self, request, database):
|
||||
return await stream_csv(self.ds, self.data, request, database)
|
||||
|
||||
async def get(self, request):
|
||||
db = await self.ds.resolve_database(request)
|
||||
database = db.name
|
||||
database_route = db.route
|
||||
format_ = request.url_vars.get("format") or "html"
|
||||
data_kwargs = {}
|
||||
|
||||
if format_ == "csv":
|
||||
return await self.as_csv(request, database_route)
|
||||
|
||||
if format_ == "html":
|
||||
# HTML views default to expanding all foreign key labels
|
||||
data_kwargs["default_labels"] = True
|
||||
|
||||
extra_template_data = {}
|
||||
start = time.perf_counter()
|
||||
status_code = None
|
||||
templates = ()
|
||||
try:
|
||||
response_or_template_contexts = await self.data(request, **data_kwargs)
|
||||
if isinstance(response_or_template_contexts, Response):
|
||||
return response_or_template_contexts
|
||||
# If it has four items, it includes an HTTP status code
|
||||
if len(response_or_template_contexts) == 4:
|
||||
(
|
||||
data,
|
||||
extra_template_data,
|
||||
templates,
|
||||
status_code,
|
||||
) = response_or_template_contexts
|
||||
else:
|
||||
data, extra_template_data, templates = response_or_template_contexts
|
||||
except QueryInterrupted as ex:
|
||||
raise DatasetteError(
|
||||
textwrap.dedent("""
|
||||
<p>SQL query took too long. The time limit is controlled by the
|
||||
<a href="https://docs.datasette.io/en/stable/settings.html#sql-time-limit-ms">sql_time_limit_ms</a>
|
||||
configuration option.</p>
|
||||
<textarea style="width: 90%">{}</textarea>
|
||||
<script>
|
||||
let ta = document.querySelector("textarea");
|
||||
ta.style.height = ta.scrollHeight + "px";
|
||||
</script>
|
||||
""".format(markupsafe.escape(ex.sql))).strip(),
|
||||
title="SQL Interrupted",
|
||||
status=400,
|
||||
message_is_html=True,
|
||||
)
|
||||
except (sqlite3.OperationalError, InvalidSql) as e:
|
||||
raise DatasetteError(str(e), title="Invalid SQL", status=400)
|
||||
except sqlite3.OperationalError as e:
|
||||
raise DatasetteError(str(e))
|
||||
except DatasetteError:
|
||||
raise
|
||||
|
||||
end = time.perf_counter()
|
||||
data["query_ms"] = (end - start) * 1000
|
||||
|
||||
# Special case for .jsono extension - redirect to _shape=objects
|
||||
if format_ == "jsono":
|
||||
return self.redirect(
|
||||
request,
|
||||
path_with_added_args(
|
||||
request,
|
||||
{"_shape": "objects"},
|
||||
path=request.path.rsplit(".jsono", 1)[0] + ".json",
|
||||
),
|
||||
forward_querystring=False,
|
||||
)
|
||||
|
||||
if format_ in self.ds.renderers.keys():
|
||||
# Dispatch request to the correct output format renderer
|
||||
# (CSV is not handled here due to streaming)
|
||||
result = call_with_supported_arguments(
|
||||
self.ds.renderers[format_][0],
|
||||
datasette=self.ds,
|
||||
columns=data.get("columns") or [],
|
||||
rows=data.get("rows") or [],
|
||||
sql=data.get("query", {}).get("sql", None),
|
||||
query_name=data.get("query_name"),
|
||||
database=database,
|
||||
table=data.get("table"),
|
||||
request=request,
|
||||
view_name=self.name,
|
||||
truncated=False, # TODO: support this
|
||||
error=data.get("error"),
|
||||
# These will be deprecated in Datasette 1.0:
|
||||
args=request.args,
|
||||
data=data,
|
||||
)
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
if result is None:
|
||||
raise NotFound("No data")
|
||||
if isinstance(result, dict):
|
||||
response = Response(
|
||||
body=result.get("body"),
|
||||
status=result.get("status_code", status_code or 200),
|
||||
content_type=result.get("content_type", "text/plain"),
|
||||
headers=result.get("headers"),
|
||||
)
|
||||
elif isinstance(result, Response):
|
||||
response = result
|
||||
if status_code is not None:
|
||||
# Over-ride the status code
|
||||
response.status = status_code
|
||||
else:
|
||||
assert False, f"{result} should be dict or Response"
|
||||
elif format_ == "html":
|
||||
response = await self.html(request, data, extra_template_data, templates)
|
||||
if status_code is not None:
|
||||
response.status = status_code
|
||||
else:
|
||||
raise NotFound("Invalid format: {}".format(format_))
|
||||
|
||||
ttl = request.args.get("_ttl", None)
|
||||
if ttl is None or not ttl.isdigit():
|
||||
ttl = self.ds.setting("default_cache_ttl")
|
||||
|
||||
return self.set_response_headers(response, ttl)
|
||||
|
||||
async def html(self, request, data, extra_template_data, templates):
|
||||
extras = {}
|
||||
if callable(extra_template_data):
|
||||
extras = extra_template_data()
|
||||
if asyncio.iscoroutine(extras):
|
||||
extras = await extras
|
||||
else:
|
||||
extras = extra_template_data
|
||||
|
||||
url_labels_extra = {}
|
||||
if data.get("expandable_columns"):
|
||||
url_labels_extra = {"_labels": "on"}
|
||||
|
||||
renderers = {}
|
||||
for key, (_, can_render) in self.ds.renderers.items():
|
||||
it_can_render = call_with_supported_arguments(
|
||||
can_render,
|
||||
datasette=self.ds,
|
||||
columns=data.get("columns") or [],
|
||||
rows=data.get("rows") or [],
|
||||
sql=data.get("query", {}).get("sql", None),
|
||||
query_name=data.get("query_name"),
|
||||
database=data.get("database"),
|
||||
table=data.get("table"),
|
||||
request=request,
|
||||
view_name=self.name,
|
||||
)
|
||||
it_can_render = await await_me_maybe(it_can_render)
|
||||
if it_can_render:
|
||||
renderers[key] = self.ds.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format=key,
|
||||
extra_qs={**url_labels_extra},
|
||||
)
|
||||
)
|
||||
|
||||
url_csv_args = {"_size": "max", **url_labels_extra}
|
||||
url_csv = self.ds.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format="csv",
|
||||
extra_qs=url_csv_args,
|
||||
)
|
||||
)
|
||||
url_csv_path = url_csv.split("?")[0]
|
||||
context = {**data, **extras}
|
||||
if "metadata" not in context:
|
||||
context["metadata"] = await self.ds.get_instance_metadata()
|
||||
|
||||
environment = self.ds.get_jinja_environment(request)
|
||||
template = environment.select_template(templates)
|
||||
alternate_url_json = self.ds.absolute_url(
|
||||
request,
|
||||
self.ds.urls.path(
|
||||
path_with_format(
|
||||
request=request,
|
||||
path=request.scope.get("route_path"),
|
||||
format="json",
|
||||
)
|
||||
),
|
||||
)
|
||||
return Response.html(
|
||||
await self.ds.render_template(
|
||||
template,
|
||||
RowContext(
|
||||
columns=context["columns"],
|
||||
database=context["database"],
|
||||
database_color=context["database_color"],
|
||||
foreign_key_tables=context["foreign_key_tables"],
|
||||
metadata=context["metadata"],
|
||||
primary_keys=context["primary_keys"],
|
||||
private=context["private"],
|
||||
table=context["table"],
|
||||
ok=context["ok"],
|
||||
rows=context["rows"],
|
||||
primary_key_values=context["primary_key_values"],
|
||||
query_ms=context["query_ms"],
|
||||
display_columns=context["display_columns"],
|
||||
display_rows=context["display_rows"],
|
||||
custom_table_templates=context["custom_table_templates"],
|
||||
row_actions=context["row_actions"],
|
||||
row_mutation_ui=context["row_mutation_ui"],
|
||||
table_page_data=context["table_page_data"],
|
||||
top_row=context["top_row"],
|
||||
renderers=renderers,
|
||||
url_csv=url_csv,
|
||||
url_csv_path=url_csv_path,
|
||||
url_csv_hidden_args=[
|
||||
(key, value)
|
||||
for key, value in urllib.parse.parse_qsl(request.query_string)
|
||||
if key not in ("_labels", "_facet", "_size")
|
||||
]
|
||||
+ [("_size", "max")],
|
||||
settings=self.ds.settings_dict(),
|
||||
select_templates=[
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
],
|
||||
alternate_url_json=alternate_url_json,
|
||||
),
|
||||
request=request,
|
||||
view_name=self.name,
|
||||
),
|
||||
headers={
|
||||
"Link": '<{}>; rel="alternate"; type="application/json+datasette"'.format(
|
||||
alternate_url_json
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def set_response_headers(self, response, ttl):
|
||||
# Set far-future cache expiry
|
||||
if self.ds.cache_headers and response.status == 200:
|
||||
ttl = int(ttl)
|
||||
if ttl == 0:
|
||||
ttl_header = "no-cache"
|
||||
else:
|
||||
ttl_header = f"max-age={ttl}"
|
||||
response.headers["Cache-Control"] = ttl_header
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
if self.ds.cors:
|
||||
add_cors_headers(response.headers)
|
||||
return response
|
||||
|
||||
async def data(self, request, default_labels=False):
|
||||
resolved = await self.ds.resolve_row(request)
|
||||
db = resolved.db
|
||||
|
|
|
|||
|
|
@ -46,6 +46,10 @@ from datasette.utils import (
|
|||
from datasette.utils.asgi import BadRequest, Forbidden, NotFound, Request, Response
|
||||
from datasette.filters import Filters
|
||||
import sqlite_utils
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from datasette.extras import ExtraScope
|
||||
from . import Context, from_extra
|
||||
from .base import BaseView, DatasetteError, _error, stream_csv
|
||||
from .database import QueryView
|
||||
from .table_create_alter import (
|
||||
|
|
@ -64,6 +68,153 @@ from .table_extras import (
|
|||
table_extra_registry,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableContext(Context):
|
||||
"The page showing the rows in a table or SQL view, e.g. /fixtures/facetable."
|
||||
|
||||
documented_template = "table.html"
|
||||
extras_scope = ExtraScope.TABLE
|
||||
|
||||
# Fields resolved by registered extras - their documentation comes
|
||||
# from the description on each Extra class in table_extras.py
|
||||
actions: callable = from_extra()
|
||||
all_columns: list = from_extra()
|
||||
columns: list = from_extra()
|
||||
count: int = from_extra()
|
||||
count_sql: str = from_extra()
|
||||
custom_table_templates: list = from_extra()
|
||||
database: str = from_extra()
|
||||
database_color: str = from_extra()
|
||||
display_columns: list = from_extra()
|
||||
display_rows: list = from_extra()
|
||||
expandable_columns: list = from_extra()
|
||||
facet_results: dict = from_extra()
|
||||
facets_timed_out: list = from_extra()
|
||||
filters: Filters = from_extra()
|
||||
form_hidden_args: list = from_extra()
|
||||
human_description_en: str = from_extra()
|
||||
is_view: bool = from_extra()
|
||||
metadata: dict = from_extra()
|
||||
next_url: str = from_extra()
|
||||
primary_keys: list = from_extra()
|
||||
private: bool = from_extra()
|
||||
query: dict = from_extra()
|
||||
renderers: dict = from_extra()
|
||||
set_column_type_ui: dict = from_extra()
|
||||
sorted_facet_results: list = from_extra()
|
||||
suggested_facets: list = from_extra()
|
||||
table: str = from_extra()
|
||||
table_definition: str = from_extra()
|
||||
view_definition: str = from_extra()
|
||||
|
||||
# Fields added by the view code
|
||||
ok: bool = field(
|
||||
metadata={"help": "True if the data for this page was retrieved without errors"}
|
||||
)
|
||||
next: str = field(metadata={"help": "Pagination token for the next page, or None"})
|
||||
count_truncated: bool = field(
|
||||
metadata={
|
||||
"help": "True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit."
|
||||
}
|
||||
)
|
||||
rows: list = field(
|
||||
metadata={
|
||||
"help": "The rows for this page, as a list of dictionaries mapping column name to raw value."
|
||||
}
|
||||
)
|
||||
filter_columns: list = field(
|
||||
metadata={
|
||||
"help": "List of column names offered by the filter interface, including currently displayed columns and any hidden columns that can still be filtered."
|
||||
}
|
||||
)
|
||||
supports_search: bool = field(
|
||||
metadata={"help": "True if this table has full-text search configured"}
|
||||
)
|
||||
extra_wheres_for_ui: list = field(
|
||||
metadata={
|
||||
"help": "Extra where clauses from ``?_where=`` for display in the UI. Each item has ``text`` for the SQL fragment and ``remove_url`` for a URL that removes that fragment."
|
||||
}
|
||||
)
|
||||
url_csv: str = field(metadata={"help": "URL for the CSV export of this page"})
|
||||
url_csv_path: str = field(metadata={"help": "Path portion of the CSV export URL"})
|
||||
url_csv_hidden_args: list = field(
|
||||
metadata={
|
||||
"help": "List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current filters while forcing ``_size=max``."
|
||||
}
|
||||
)
|
||||
sort: str = field(metadata={"help": "Column the page is sorted by, or None"})
|
||||
sort_desc: str = field(
|
||||
metadata={"help": "Column the page is sorted by in descending order, or None"}
|
||||
)
|
||||
append_querystring: callable = field(
|
||||
metadata={
|
||||
"help": "Function ``append_querystring(url, querystring)`` that appends additional query string arguments to a URL, using ``?`` or ``&`` as appropriate."
|
||||
}
|
||||
)
|
||||
path_with_replaced_args: callable = field(
|
||||
metadata={
|
||||
"help": "Function for building the current path with modified query string arguments. Pass the current ``request`` and a dictionary of argument names to replacement values, using ``None`` to remove an argument."
|
||||
}
|
||||
)
|
||||
fix_path: callable = field(
|
||||
metadata={
|
||||
"help": "Function that applies the configured ``base_url`` prefix to a path."
|
||||
}
|
||||
)
|
||||
settings: dict = field(
|
||||
metadata={
|
||||
"help": "Dictionary of Datasette's current settings, keyed by setting name."
|
||||
}
|
||||
)
|
||||
alternate_url_json: str = field(
|
||||
metadata={"help": "URL for the JSON version of this page"}
|
||||
)
|
||||
datasette_allow_facet: str = field(
|
||||
metadata={
|
||||
"help": 'The string "true" or "false" reflecting the allow_facet setting'
|
||||
}
|
||||
)
|
||||
is_sortable: bool = field(
|
||||
metadata={"help": "True if any of the displayed columns can be used to sort"}
|
||||
)
|
||||
allow_execute_sql: bool = field(
|
||||
metadata={
|
||||
"help": "True if the current actor can execute custom SQL against this database"
|
||||
}
|
||||
)
|
||||
query_ms: float = field(
|
||||
metadata={
|
||||
"help": "Time taken by the SQL queries for this page, in milliseconds"
|
||||
}
|
||||
)
|
||||
select_templates: list = field(
|
||||
metadata={
|
||||
"help": "List of template names that were considered for this page, with the selected template prefixed by ``*``."
|
||||
}
|
||||
)
|
||||
top_table: callable = field(
|
||||
metadata={
|
||||
"help": "Async callable that renders the ``top_table`` plugin slot for this table or view and returns HTML."
|
||||
}
|
||||
)
|
||||
table_page_data: dict = field(
|
||||
metadata={
|
||||
"help": "JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data."
|
||||
}
|
||||
)
|
||||
table_insert_ui: dict = field(
|
||||
metadata={
|
||||
"help": "Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys."
|
||||
}
|
||||
)
|
||||
table_alter_ui: dict = field(
|
||||
metadata={
|
||||
"help": "Information needed to enable the alter table UI, or ``None`` if altering this table is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``primaryKeys``, ``columnTypes``, ``defaultExpressions`` and ``foreignKeyTargetsPath`` keys, plus optional ``customColumnTypes`` and ``dropPath`` keys."
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
LINK_WITH_LABEL = (
|
||||
'<a href="{base_url}{database}/{table}/{link_id}">{label}</a> <em>{id}</em>'
|
||||
)
|
||||
|
|
@ -1260,7 +1411,6 @@ class TableFragmentView(BaseView):
|
|||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=self.ds.urls.path,
|
||||
settings=self.ds.settings_dict(),
|
||||
count_limit=resolved.db.count_limit,
|
||||
),
|
||||
request=request,
|
||||
view_name="table",
|
||||
|
|
@ -1675,40 +1825,82 @@ async def table_view_traced(datasette, request):
|
|||
)
|
||||
}
|
||||
)
|
||||
table_context = TableContext(
|
||||
actions=data["actions"],
|
||||
all_columns=data["all_columns"],
|
||||
columns=data["columns"],
|
||||
count=data["count"],
|
||||
count_sql=data["count_sql"],
|
||||
custom_table_templates=data["custom_table_templates"],
|
||||
database=data["database"],
|
||||
database_color=data["database_color"],
|
||||
display_columns=data["display_columns"],
|
||||
display_rows=data["display_rows"],
|
||||
expandable_columns=data["expandable_columns"],
|
||||
facet_results=data["facet_results"],
|
||||
facets_timed_out=data["facets_timed_out"],
|
||||
filters=data["filters"],
|
||||
form_hidden_args=data["form_hidden_args"],
|
||||
human_description_en=data["human_description_en"],
|
||||
is_view=data["is_view"],
|
||||
metadata=data["metadata"],
|
||||
next_url=data["next_url"],
|
||||
primary_keys=data["primary_keys"],
|
||||
private=data["private"],
|
||||
query=data["query"],
|
||||
renderers=data["renderers"],
|
||||
set_column_type_ui=data["set_column_type_ui"],
|
||||
sorted_facet_results=data["sorted_facet_results"],
|
||||
suggested_facets=data["suggested_facets"],
|
||||
table=data["table"],
|
||||
table_definition=data["table_definition"],
|
||||
view_definition=data["view_definition"],
|
||||
ok=data["ok"],
|
||||
next=data["next"],
|
||||
count_truncated=data["count_truncated"],
|
||||
rows=data["rows"],
|
||||
filter_columns=data["filter_columns"],
|
||||
supports_search=data["supports_search"],
|
||||
extra_wheres_for_ui=data["extra_wheres_for_ui"],
|
||||
url_csv=data["url_csv"],
|
||||
url_csv_path=data["url_csv_path"],
|
||||
url_csv_hidden_args=data["url_csv_hidden_args"],
|
||||
sort=data["sort"],
|
||||
sort_desc=data["sort_desc"],
|
||||
append_querystring=append_querystring,
|
||||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=datasette.urls.path,
|
||||
settings=datasette.settings_dict(),
|
||||
alternate_url_json=alternate_url_json,
|
||||
datasette_allow_facet=(
|
||||
"true" if datasette.setting("allow_facet") else "false"
|
||||
),
|
||||
is_sortable=any(c["sortable"] for c in data["display_columns"]),
|
||||
allow_execute_sql=await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=resolved.db.name),
|
||||
actor=request.actor,
|
||||
),
|
||||
query_ms=1.2,
|
||||
select_templates=[
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
],
|
||||
top_table=make_slot_function(
|
||||
"top_table",
|
||||
datasette,
|
||||
request,
|
||||
database=resolved.db.name,
|
||||
table=resolved.table,
|
||||
),
|
||||
table_page_data=data["table_page_data"],
|
||||
table_insert_ui=data["table_insert_ui"],
|
||||
table_alter_ui=data["table_alter_ui"],
|
||||
)
|
||||
r = Response.html(
|
||||
await datasette.render_template(
|
||||
template,
|
||||
dict(
|
||||
data,
|
||||
append_querystring=append_querystring,
|
||||
path_with_replaced_args=path_with_replaced_args,
|
||||
fix_path=datasette.urls.path,
|
||||
settings=datasette.settings_dict(),
|
||||
# TODO: review up all of these hacks:
|
||||
alternate_url_json=alternate_url_json,
|
||||
datasette_allow_facet=(
|
||||
"true" if datasette.setting("allow_facet") else "false"
|
||||
),
|
||||
is_sortable=any(c["sortable"] for c in data["display_columns"]),
|
||||
allow_execute_sql=await datasette.allowed(
|
||||
action="execute-sql",
|
||||
resource=DatabaseResource(database=resolved.db.name),
|
||||
actor=request.actor,
|
||||
),
|
||||
query_ms=1.2,
|
||||
select_templates=[
|
||||
f"{'*' if template_name == template.name else ''}{template_name}"
|
||||
for template_name in templates
|
||||
],
|
||||
top_table=make_slot_function(
|
||||
"top_table",
|
||||
datasette,
|
||||
request,
|
||||
database=resolved.db.name,
|
||||
table=resolved.table,
|
||||
),
|
||||
count_limit=resolved.db.count_limit,
|
||||
),
|
||||
table_context,
|
||||
request=request,
|
||||
view_name="table",
|
||||
),
|
||||
|
|
@ -2140,6 +2332,9 @@ async def table_view_data(
|
|||
data["rows"] = transformed_rows
|
||||
|
||||
if context_for_html_hack:
|
||||
data["count_truncated"] = _count_truncated_for_table_page(
|
||||
datasette, db, database_name, table_name, count_sql, data.get("count")
|
||||
)
|
||||
data.update(extra_context_from_filters)
|
||||
# filter_columns combine the columns we know are available
|
||||
# in the table with any additional columns (such as rowid)
|
||||
|
|
@ -2205,6 +2400,24 @@ async def table_view_data(
|
|||
return data, rows[:page_size], columns, expanded_columns, sql, next_url
|
||||
|
||||
|
||||
def _count_truncated_for_table_page(
|
||||
datasette, db, database_name, table_name, count_sql, count
|
||||
):
|
||||
if count != db.count_limit + 1:
|
||||
return False
|
||||
if (
|
||||
not db.is_mutable
|
||||
and datasette.inspect_data
|
||||
and count_sql == f"select count(*) from {table_name} "
|
||||
):
|
||||
try:
|
||||
datasette.inspect_data[database_name]["tables"][table_name]["count"]
|
||||
return False
|
||||
except KeyError:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
async def _next_value_and_url(
|
||||
datasette,
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ class QueryExtraContext:
|
|||
|
||||
|
||||
class CountSqlExtra(Extra):
|
||||
description = "SQL query used to calculate the total count"
|
||||
description = "SQL query string used to calculate the total count for the current table view, including active filters."
|
||||
example = ExtraExample("/fixtures/facetable.json?_size=0&_extra=count_sql")
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
|
|
@ -127,8 +127,8 @@ class CountExtra(Extra):
|
|||
pass
|
||||
|
||||
if context.count_sql and count is None and not context.nocount:
|
||||
count_sql_limited = (
|
||||
f"select count(*) from (select * {context.from_sql} limit 10001)"
|
||||
count_sql_limited = "select count(*) from (select * {} limit {})".format(
|
||||
context.from_sql, context.db.count_limit + 1
|
||||
)
|
||||
try:
|
||||
count_rows = list(
|
||||
|
|
@ -165,7 +165,7 @@ class FacetInstancesProvider(Provider):
|
|||
|
||||
|
||||
class FacetResultsExtra(Extra):
|
||||
description = "Results of facets calculated against this data"
|
||||
description = "Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``."
|
||||
example = ExtraExample(
|
||||
value={
|
||||
"results": {
|
||||
|
|
@ -214,7 +214,9 @@ class FacetResultsExtra(Extra):
|
|||
|
||||
|
||||
class FacetsTimedOutExtra(Extra):
|
||||
description = "Facet calculations that timed out"
|
||||
description = (
|
||||
"List of names of facet calculations that exceeded the facet time limit."
|
||||
)
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_facet=state&_extra=facets_timed_out",
|
||||
note=(
|
||||
|
|
@ -230,7 +232,7 @@ class FacetsTimedOutExtra(Extra):
|
|||
|
||||
|
||||
class SuggestedFacetsExtra(Extra):
|
||||
description = "Suggestions for facets that might return interesting results"
|
||||
description = "Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class."
|
||||
example = ExtraExample(
|
||||
value=[
|
||||
{
|
||||
|
|
@ -301,7 +303,7 @@ class NextUrlExtra(Extra):
|
|||
|
||||
|
||||
class ColumnsExtra(Extra):
|
||||
description = "Column names returned by this query"
|
||||
description = "List of column names returned by this table, row or query."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=columns")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -318,7 +320,7 @@ class ColumnsExtra(Extra):
|
|||
|
||||
|
||||
class AllColumnsExtra(Extra):
|
||||
description = "All columns in the table, regardless of _col/_nocol filtering"
|
||||
description = "List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering."
|
||||
example = ExtraExample("/fixtures/facetable.json?_col=pk&_extra=all_columns")
|
||||
scopes = {ExtraScope.TABLE}
|
||||
|
||||
|
|
@ -327,7 +329,7 @@ class AllColumnsExtra(Extra):
|
|||
|
||||
|
||||
class PrimaryKeysExtra(Extra):
|
||||
description = "Primary keys for this table"
|
||||
description = "List of primary key column names for this table, or an empty list if the table has no explicit primary key."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=primary_keys")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -341,7 +343,7 @@ class PrimaryKeysExtra(Extra):
|
|||
|
||||
|
||||
class ActionsExtra(Extra):
|
||||
description = "Table or view actions made available by plugin hooks"
|
||||
description = 'Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.'
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Returns an async function for the HTML templates - not JSON serializable
|
||||
public = False
|
||||
|
|
@ -421,7 +423,7 @@ class IsViewExtra(Extra):
|
|||
|
||||
|
||||
class DebugExtra(Extra):
|
||||
description = "Extra debug information"
|
||||
description = "Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract."
|
||||
docs_note = (
|
||||
"The contents of this block are not a stable part of the Datasette "
|
||||
"API and may change without warning."
|
||||
|
|
@ -457,7 +459,7 @@ class DebugExtra(Extra):
|
|||
|
||||
|
||||
class RequestExtra(Extra):
|
||||
description = "Full information about the request"
|
||||
description = "Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values."
|
||||
example = ExtraExample("/fixtures/facetable.json?_extra=request")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -501,7 +503,7 @@ class DisplayColumnsAndRowsProvider(Provider):
|
|||
|
||||
|
||||
class DisplayColumnsExtra(Extra):
|
||||
description = "Column metadata used by the HTML table display"
|
||||
description = "Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys."
|
||||
example = ExtraExample(
|
||||
value=[
|
||||
{
|
||||
|
|
@ -531,7 +533,7 @@ class DisplayColumnsExtra(Extra):
|
|||
|
||||
|
||||
class DisplayRowsExtra(Extra):
|
||||
description = "Row data formatted for the HTML table display"
|
||||
description = "Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object."
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Contains markupsafe/sqlite3.Row values - not JSON serializable
|
||||
public = False
|
||||
|
|
@ -640,7 +642,7 @@ class RenderCellExtra(Extra):
|
|||
|
||||
|
||||
class QueryExtra(Extra):
|
||||
description = "Details of the underlying SQL query"
|
||||
description = "Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys."
|
||||
example = ExtraExample("/fixtures/facetable.json?_size=1&_extra=query")
|
||||
examples = {
|
||||
ExtraScope.ROW: ExtraExample(
|
||||
|
|
@ -661,7 +663,7 @@ class QueryExtra(Extra):
|
|||
|
||||
|
||||
class ColumnTypesExtra(Extra):
|
||||
description = "Column type assignments for this table"
|
||||
description = 'Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries.'
|
||||
docs_note = (
|
||||
"An empty object if no column types have been assigned. Column types "
|
||||
"can be assigned in :ref:`configuration "
|
||||
|
|
@ -700,7 +702,7 @@ class ColumnTypesExtra(Extra):
|
|||
|
||||
|
||||
class SetColumnTypeUiExtra(Extra):
|
||||
description = "Information needed to build an interface for assigning column types"
|
||||
description = "Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values."
|
||||
docs_note = (
|
||||
"``null`` unless the current actor is allowed to use the :ref:`set "
|
||||
"column type API <TableSetColumnTypeView>` for this table."
|
||||
|
|
@ -784,7 +786,7 @@ class SetColumnTypeUiExtra(Extra):
|
|||
|
||||
|
||||
class MetadataExtra(Extra):
|
||||
description = "Metadata about the table, database or stored query"
|
||||
description = "Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration."
|
||||
docs_note = "See :ref:`metadata` for how to attach metadata to tables."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_extra=metadata",
|
||||
|
|
@ -891,7 +893,7 @@ class DatabaseColorExtra(Extra):
|
|||
|
||||
|
||||
class FormHiddenArgsExtra(Extra):
|
||||
description = "Hidden form arguments used by the HTML table interface"
|
||||
description = "List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args"
|
||||
)
|
||||
|
|
@ -911,7 +913,7 @@ class FormHiddenArgsExtra(Extra):
|
|||
|
||||
|
||||
class FiltersExtra(Extra):
|
||||
description = "Filters object used by the HTML table interface"
|
||||
description = "``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable."
|
||||
scopes = {ExtraScope.TABLE}
|
||||
# Returns a Filters instance for the HTML templates - not JSON serializable
|
||||
public = False
|
||||
|
|
@ -921,7 +923,7 @@ class FiltersExtra(Extra):
|
|||
|
||||
|
||||
class CustomTableTemplatesExtra(Extra):
|
||||
description = "Custom template names considered for this table"
|
||||
description = "List of custom template names considered for rendering table rows, in lookup order."
|
||||
docs_note = (
|
||||
"The first template in this list that exists will be used to render "
|
||||
"the table on the HTML version of this page. See "
|
||||
|
|
@ -939,7 +941,7 @@ class CustomTableTemplatesExtra(Extra):
|
|||
|
||||
|
||||
class SortedFacetResultsExtra(Extra):
|
||||
description = "Facet results sorted for display"
|
||||
description = "Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``."
|
||||
docs_note = (
|
||||
"The same data as ``facet_results``, as a list in the order used by "
|
||||
"the HTML interface: facets from :ref:`facet configuration "
|
||||
|
|
@ -1001,7 +1003,7 @@ class ViewDefinitionExtra(Extra):
|
|||
|
||||
|
||||
class RenderersExtra(Extra):
|
||||
description = "Alternative output renderers available for this table"
|
||||
description = "Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_extra=renderers",
|
||||
note=(
|
||||
|
|
@ -1068,7 +1070,7 @@ class PrivateExtra(Extra):
|
|||
|
||||
|
||||
class ExpandableColumnsExtra(Extra):
|
||||
description = "Foreign key columns that can be expanded with labels"
|
||||
description = "List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``."
|
||||
docs_note = "See :ref:`expand_foreign_keys` for how to expand these labels."
|
||||
example = ExtraExample(
|
||||
"/fixtures/facetable.json?_extra=expandable_columns",
|
||||
|
|
@ -1090,7 +1092,7 @@ class ExpandableColumnsExtra(Extra):
|
|||
|
||||
|
||||
class ForeignKeyTablesExtra(Extra):
|
||||
description = "Tables that link to this row using foreign keys"
|
||||
description = "List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL."
|
||||
example = ExtraExample(
|
||||
"/fixtures/simple_primary_key/1.json?_extra=foreign_key_tables",
|
||||
note=(
|
||||
|
|
@ -1108,7 +1110,7 @@ class ForeignKeyTablesExtra(Extra):
|
|||
|
||||
|
||||
class ExtrasExtra(Extra):
|
||||
description = "List of ?_extra= blocks that can be used on this page"
|
||||
description = "List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys."
|
||||
example = ExtraExample(
|
||||
value=[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -309,6 +309,19 @@ To update these pages, run the following command::
|
|||
|
||||
uv run cog -r docs/*.rst
|
||||
|
||||
.. _contributing_template_contexts:
|
||||
|
||||
Documented template contexts
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Datasette's documented template contexts are part of the public API for custom templates. They are defined as dataclasses next to the view code that renders them, for example ``DatabaseContext`` and ``QueryContext`` in ``datasette/views/database.py``.
|
||||
|
||||
Every documented context class inherits from ``datasette.views.Context``. Fields that are added directly by view code should be declared as dataclass fields with ``help`` metadata, which is used to generate :ref:`template_context`. Fields resolved through the page extras system should use ``from_extra()`` so their documentation comes from the matching ``Extra`` class.
|
||||
|
||||
Use ``documented_template`` on each context class to record the canonical template named in the generated documentation. This should be a string such as ``"database.html"``. Runtime template selection still happens in the view code, since most pages consider more specific template names before falling back to the canonical one.
|
||||
|
||||
When a context field contains repeated structured data, prefer a small nested dataclass over an anonymous dictionary. For example, a field containing table summaries should be annotated as ``list[DatabaseTable]`` where ``DatabaseTable`` is a dataclass describing the keys and value types. This keeps the Python contract and generated documentation clear. JSON responses and ``?_context=1`` debug output will convert nested dataclasses back to JSON objects at the response boundary.
|
||||
|
||||
.. _contributing_continuous_deployment:
|
||||
|
||||
Continuously deployed demo instances
|
||||
|
|
|
|||
|
|
@ -177,6 +177,11 @@ this::
|
|||
Datasette will now first look for templates in that directory, and fall back on
|
||||
the defaults if no matches are found.
|
||||
|
||||
The variables made available to each template are documented on the
|
||||
:ref:`template_context` page. Variables documented there are a stable API:
|
||||
custom templates that use them will keep working in future Datasette
|
||||
releases, up until the next major version.
|
||||
|
||||
It is also possible to over-ride templates on a per-database, per-row or per-
|
||||
table basis.
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ Contents
|
|||
settings
|
||||
introspection
|
||||
custom_templates
|
||||
template_context
|
||||
plugins
|
||||
writing_plugins
|
||||
javascript_plugins
|
||||
|
|
|
|||
|
|
@ -267,7 +267,7 @@ The available table extras are listed below.
|
|||
15
|
||||
|
||||
``count_sql``
|
||||
SQL query used to calculate the total count
|
||||
SQL query string used to calculate the total count for the current table view, including active filters.
|
||||
|
||||
``GET /fixtures/facetable.json?_size=0&_extra=count_sql``
|
||||
|
||||
|
|
@ -276,7 +276,7 @@ The available table extras are listed below.
|
|||
"select count(*) from facetable "
|
||||
|
||||
``facet_results``
|
||||
Results of facets calculated against this data (May execute additional queries. See :ref:`facets` for details of how facets work.)
|
||||
Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``. (May execute additional queries. See :ref:`facets` for details of how facets work.)
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_facet=state&_extra=facet_results.
|
||||
|
||||
|
|
@ -305,7 +305,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``facets_timed_out``
|
||||
Facet calculations that timed out
|
||||
List of names of facet calculations that exceeded the facet time limit.
|
||||
|
||||
``GET /fixtures/facetable.json?_facet=state&_extra=facets_timed_out``
|
||||
|
||||
|
|
@ -316,7 +316,7 @@ The available table extras are listed below.
|
|||
[]
|
||||
|
||||
``suggested_facets``
|
||||
Suggestions for facets that might return interesting results (May execute additional queries. Suggestions are controlled by the :ref:`setting_suggest_facets` setting.)
|
||||
Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class. (May execute additional queries. Suggestions are controlled by the :ref:`setting_suggest_facets` setting.)
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=suggested_facets.
|
||||
|
||||
|
|
@ -350,7 +350,7 @@ The available table extras are listed below.
|
|||
"http://localhost/fixtures/facetable.json?_size=1&_extra=next_url&_next=1"
|
||||
|
||||
``columns``
|
||||
Column names returned by this query
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=columns``
|
||||
|
||||
|
|
@ -371,7 +371,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``all_columns``
|
||||
All columns in the table, regardless of _col/_nocol filtering
|
||||
List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering.
|
||||
|
||||
``GET /fixtures/facetable.json?_col=pk&_extra=all_columns``
|
||||
|
||||
|
|
@ -392,7 +392,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``primary_keys``
|
||||
Primary keys for this table
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=primary_keys``
|
||||
|
||||
|
|
@ -403,7 +403,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``display_columns``
|
||||
Column metadata used by the HTML table display
|
||||
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_size=1&_extra=display_columns.
|
||||
|
||||
|
|
@ -456,7 +456,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``debug``
|
||||
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=debug``
|
||||
|
||||
|
|
@ -474,7 +474,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``request``
|
||||
Full information about the request
|
||||
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=request``
|
||||
|
||||
|
|
@ -493,7 +493,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``query``
|
||||
Details of the underlying SQL query
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``GET /fixtures/facetable.json?_size=1&_extra=query``
|
||||
|
||||
|
|
@ -505,7 +505,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``column_types``
|
||||
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries. (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
|
||||
``GET /fixtures/facetable.json?_size=0&_extra=column_types``
|
||||
|
||||
|
|
@ -521,7 +521,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``set_column_type_ui``
|
||||
Information needed to build an interface for assigning column types (``null`` unless the current actor is allowed to use the :ref:`set column type API <TableSetColumnTypeView>` for this table.)
|
||||
Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values. (``null`` unless the current actor is allowed to use the :ref:`set column type API <TableSetColumnTypeView>` for this table.)
|
||||
|
||||
Shape abbreviated to two columns, as seen by an actor with ``set-column-type`` permission. ``current`` is the column type currently assigned to each column and ``options`` lists the types that could be assigned to it.
|
||||
|
||||
|
|
@ -571,7 +571,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``metadata``
|
||||
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=metadata``
|
||||
|
||||
|
|
@ -587,7 +587,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``extras``
|
||||
List of ?_extra= blocks that can be used on this page
|
||||
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
|
||||
|
||||
|
|
@ -636,7 +636,7 @@ The available table extras are listed below.
|
|||
"9403e5"
|
||||
|
||||
``renderers``
|
||||
Alternative output renderers available for this table
|
||||
Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format.
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=renderers``
|
||||
|
||||
|
|
@ -649,7 +649,7 @@ The available table extras are listed below.
|
|||
}
|
||||
|
||||
``custom_table_templates``
|
||||
Custom template names considered for this table (The first template in this list that exists will be used to render the table on the HTML version of this page. See :ref:`customization_custom_templates`.)
|
||||
List of custom template names considered for rendering table rows, in lookup order. (The first template in this list that exists will be used to render the table on the HTML version of this page. See :ref:`customization_custom_templates`.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=custom_table_templates``
|
||||
|
||||
|
|
@ -662,7 +662,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``sorted_facet_results``
|
||||
Facet results sorted for display (The same data as ``facet_results``, as a list in the order used by the HTML interface: facets from :ref:`facet configuration <facets_metadata>` first, then other facets ordered by their number of results.)
|
||||
Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``. (The same data as ``facet_results``, as a list in the order used by the HTML interface: facets from :ref:`facet configuration <facets_metadata>` first, then other facets ordered by their number of results.)
|
||||
|
||||
``GET /fixtures/facetable.json?_facet=state&_extra=sorted_facet_results``
|
||||
|
||||
|
|
@ -738,7 +738,7 @@ The available table extras are listed below.
|
|||
false
|
||||
|
||||
``expandable_columns``
|
||||
Foreign key columns that can be expanded with labels (See :ref:`expand_foreign_keys` for how to expand these labels.)
|
||||
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``. (See :ref:`expand_foreign_keys` for how to expand these labels.)
|
||||
|
||||
``GET /fixtures/facetable.json?_extra=expandable_columns``
|
||||
|
||||
|
|
@ -758,7 +758,7 @@ The available table extras are listed below.
|
|||
]
|
||||
|
||||
``form_hidden_args``
|
||||
Hidden form arguments used by the HTML table interface
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options.
|
||||
|
||||
``GET /fixtures/facetable.json?_facet=state&_size=1&_extra=form_hidden_args``
|
||||
|
||||
|
|
@ -785,7 +785,7 @@ Row JSON responses
|
|||
The following extras are available for row JSON responses.
|
||||
|
||||
``columns``
|
||||
Column names returned by this query
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=columns``
|
||||
|
||||
|
|
@ -797,7 +797,7 @@ The following extras are available for row JSON responses.
|
|||
]
|
||||
|
||||
``primary_keys``
|
||||
Primary keys for this table
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=primary_keys``
|
||||
|
||||
|
|
@ -829,7 +829,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``debug``
|
||||
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=debug``
|
||||
|
||||
|
|
@ -858,7 +858,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``request``
|
||||
Full information about the request
|
||||
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=request``
|
||||
|
||||
|
|
@ -877,7 +877,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``query``
|
||||
Details of the underlying SQL query
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=query``
|
||||
|
||||
|
|
@ -891,7 +891,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``column_types``
|
||||
Column type assignments for this table (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
Column type assignments for this table. A dictionary mapping column names to ``{"type": type_name, "config": config}`` dictionaries. (An empty object if no column types have been assigned. Column types can be assigned in :ref:`configuration <table_configuration_column_types>` or using the :ref:`set column type API <TableSetColumnTypeView>`.)
|
||||
|
||||
``GET /fixtures/facetable/1.json?_extra=column_types``
|
||||
|
||||
|
|
@ -907,7 +907,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``metadata``
|
||||
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=metadata``
|
||||
|
||||
|
|
@ -920,7 +920,7 @@ The following extras are available for row JSON responses.
|
|||
}
|
||||
|
||||
``extras``
|
||||
List of ?_extra= blocks that can be used on this page
|
||||
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
|
||||
|
||||
|
|
@ -978,7 +978,7 @@ The following extras are available for row JSON responses.
|
|||
false
|
||||
|
||||
``foreign_key_tables``
|
||||
Tables that link to this row using foreign keys (May execute additional queries.)
|
||||
List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL. (May execute additional queries.)
|
||||
|
||||
``GET /fixtures/simple_primary_key/1.json?_extra=foreign_key_tables``
|
||||
|
||||
|
|
@ -1030,7 +1030,7 @@ Query JSON responses
|
|||
The following extras are available for arbitrary SQL query responses and stored, named query responses.
|
||||
|
||||
``columns``
|
||||
Column names returned by this query
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=columns``
|
||||
|
||||
|
|
@ -1061,7 +1061,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``debug``
|
||||
Extra debug information (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
Extra debug information dictionary. This is intended for development only and its shape is not part of the stable template contract. (The contents of this block are not a stable part of the Datasette API and may change without warning.)
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=debug``
|
||||
|
||||
|
|
@ -1075,7 +1075,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``request``
|
||||
Full information about the request
|
||||
Dictionary with request details: ``url``, ``path``, ``full_path``, ``host`` and ``args`` where ``args`` maps query string parameter names to their values.
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=request``
|
||||
|
||||
|
|
@ -1097,7 +1097,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``query``
|
||||
Details of the underlying SQL query
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``GET /fixtures/-/query.json?sql=select+1+as+one&_extra=query``
|
||||
|
||||
|
|
@ -1120,7 +1120,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``metadata``
|
||||
Metadata about the table, database or stored query (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration. (See :ref:`metadata` for how to attach metadata to tables.)
|
||||
|
||||
``GET /fixtures/neighborhood_search.json?text=town&_extra=metadata``
|
||||
|
||||
|
|
@ -1151,7 +1151,7 @@ The following extras are available for arbitrary SQL query responses and stored,
|
|||
}
|
||||
|
||||
``extras``
|
||||
List of ?_extra= blocks that can be used on this page
|
||||
List of ``?_extra=`` blocks that can be used on this page. Each item has ``name``, ``description``, ``toggle_url`` and ``selected`` keys.
|
||||
|
||||
Shape abbreviated from /fixtures/facetable.json?_extra=extras - the full response lists every extra described on this page. ``toggle_url`` is the current URL with that extra added or removed, and ``selected`` is ``true`` for extras included in the current request.
|
||||
|
||||
|
|
|
|||
506
docs/template_context.rst
Normal file
506
docs/template_context.rst
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
.. _template_context:
|
||||
|
||||
Template context
|
||||
================
|
||||
|
||||
This page documents the variables that are available to custom templates
|
||||
for each of Datasette's core pages. See :ref:`customization_custom_templates`
|
||||
for how to provide your own templates.
|
||||
|
||||
The variables documented here are a stable contract: custom templates that
|
||||
use them will continue to work across Datasette releases, up until the next
|
||||
major version (Datasette 2.0). Anything present in the template context but
|
||||
not documented on this page is not part of that contract and may change or
|
||||
be removed in any release.
|
||||
|
||||
You can inspect the full context for any page by starting Datasette with
|
||||
``--setting template_debug 1`` and adding ``?_context=1`` to the page URL.
|
||||
|
||||
.. [[[cog
|
||||
from template_context_doc import template_context
|
||||
template_context(cog)
|
||||
.. ]]]
|
||||
|
||||
Base context
|
||||
------------
|
||||
|
||||
These variables are available on every page rendered by Datasette, including pages rendered by plugins that use :ref:`datasette.render_template() <datasette_render_template>`. Plugins can add additional variables using the :ref:`plugin_hook_extra_template_vars` hook.
|
||||
|
||||
``request``
|
||||
The current :ref:`Request object <internals_request>`, or None. Common properties include ``request.path``, ``request.args``, ``request.actor``, ``request.url_vars`` and ``request.host``.
|
||||
|
||||
``crumb_items``
|
||||
Async function returning breadcrumb navigation items for the current page. Call it with ``request=request`` plus optional ``database=`` and ``table=`` arguments; it returns a list of ``{"href": url, "label": label}`` dictionaries.
|
||||
|
||||
``urls``
|
||||
Object with methods for constructing URLs within Datasette. Common methods include ``urls.instance()``, ``urls.database(database)``, ``urls.table(database, table)``, ``urls.query(database, query)``, ``urls.row(database, table, row_path)`` and ``urls.static(path)`` - see :ref:`internals_datasette_urls`.
|
||||
|
||||
``actor``
|
||||
The currently authenticated actor dictionary, or None. Actors usually include an ``id`` key and may include any other keys supplied by authentication plugins.
|
||||
|
||||
``menu_links``
|
||||
Async function returning links for the Datasette application menu, including links added by plugins. Each item is a link dictionary with ``href`` and ``label`` keys. See :ref:`plugin_hook_menu_links`; for page action menus that can also include JavaScript-backed buttons, see :ref:`plugin_actions`.
|
||||
|
||||
``display_actor``
|
||||
Function that accepts an actor dictionary and returns the display string used in the navigation menu.
|
||||
|
||||
``show_logout``
|
||||
True if the logout link should be shown in the navigation menu
|
||||
|
||||
``app_css_hash``
|
||||
Hash of Datasette's app.css contents, used for cache busting
|
||||
|
||||
``edit_tools_js_hash``
|
||||
Hash of Datasette's edit-tools.js contents, used for cache busting
|
||||
|
||||
``table_js_hash``
|
||||
Hash of Datasette's table.js contents, used for cache busting
|
||||
|
||||
``zip``
|
||||
Python's ``zip()`` builtin, made available to template logic
|
||||
|
||||
``body_scripts``
|
||||
List of JavaScript snippets contributed by plugins using :ref:`plugin_hook_extra_body_script`. Each item is a dictionary with ``script`` containing JavaScript source and ``module`` indicating whether Datasette will wrap it in ``<script type="module">``; otherwise Datasette wraps it in a regular ``<script>`` block.
|
||||
|
||||
``format_bytes``
|
||||
Function that accepts a byte count integer and returns a human-readable string such as ``1.2 MB``.
|
||||
|
||||
``show_messages``
|
||||
Function returning any messages set for the current user, clearing them in the process. Returns a list of ``(message, type)`` pairs, where ``type`` is one of Datasette's ``INFO``, ``WARNING`` or ``ERROR`` constants.
|
||||
|
||||
``extra_css_urls``
|
||||
List of extra CSS stylesheets to include on the page. Each item is a dictionary with ``url`` and optional ``sri`` keys, from plugins and configuration.
|
||||
|
||||
``extra_js_urls``
|
||||
List of extra JavaScript URLs to include on the page. Each item is a dictionary with ``url`` plus optional ``sri`` and ``module`` keys, from plugins and configuration.
|
||||
|
||||
``base_url``
|
||||
The configured :ref:`setting_base_url` setting
|
||||
|
||||
``datasette_version``
|
||||
The version of Datasette that is running
|
||||
|
||||
Database page
|
||||
-------------
|
||||
|
||||
The page listing the tables, views and queries in a database, e.g. /fixtures. Rendered using the ``database.html`` template.
|
||||
|
||||
``allow_download`` - ``bool``
|
||||
Boolean indicating if database download is allowed
|
||||
|
||||
``allow_execute_sql`` - ``bool``
|
||||
Boolean indicating if custom SQL can be executed
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for the alternate JSON version of this page
|
||||
|
||||
``attached_databases`` - ``list``
|
||||
List of names of databases attached to this SQLite connection. This is only populated for the special ``/_memory`` database when Datasette is started with ``--crossdb`` for :ref:`cross_database_queries`.
|
||||
|
||||
``database`` - ``str``
|
||||
The name of the database
|
||||
|
||||
``database_actions`` - ``callable``
|
||||
Async callable returning action items for the database menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_database_actions`.
|
||||
|
||||
``database_color`` - ``str``
|
||||
The color assigned to the database
|
||||
|
||||
``database_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the database page. Currently ``{}`` or ``{"createTable": {...}}`` where ``createTable`` includes ``path``, ``foreignKeyTargetsPath``, ``databaseName``, ``columnTypes``, ``defaultExpressions`` and optional ``customColumnTypes``.
|
||||
|
||||
``editable`` - ``bool``
|
||||
Boolean indicating if the database is editable
|
||||
|
||||
``hidden_count`` - ``int``
|
||||
Count of hidden tables
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the database, such as ``title``, ``description``, ``license`` and ``source`` values from Datasette metadata.
|
||||
|
||||
``path`` - ``str``
|
||||
The URL path to this database
|
||||
|
||||
``private`` - ``bool``
|
||||
Boolean indicating if this is a private database
|
||||
|
||||
``queries`` - ``list[StoredQuery]``
|
||||
List of ``StoredQuery`` objects. Each has attributes including ``name``, ``sql``, ``title``, ``description``, ``description_html``, ``hide_sql``, ``fragment``, ``parameters``, ``is_write`` and ``private``.
|
||||
|
||||
``queries_count`` - ``int``
|
||||
Count of visible stored queries
|
||||
|
||||
``queries_more`` - ``bool``
|
||||
Boolean indicating if more stored queries are available
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``show_hidden`` - ``str``
|
||||
Value of _show_hidden query parameter
|
||||
|
||||
``size`` - ``int``
|
||||
The size of the database in bytes
|
||||
|
||||
``table_columns`` - ``dict``
|
||||
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
|
||||
|
||||
``tables`` - ``list[DatabaseTable]``
|
||||
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
|
||||
``top_database`` - ``callable``
|
||||
Async callable that renders the ``top_database`` plugin slot for this database and returns HTML.
|
||||
|
||||
``views`` - ``list[DatabaseViewInfo]``
|
||||
List of ``DatabaseViewInfo`` objects describing SQLite views in the database. Each item has ``name`` and ``private`` attributes.
|
||||
|
||||
Query page
|
||||
----------
|
||||
|
||||
The page for arbitrary SQL queries (/database/-/query?sql=...) and stored queries (/database/query-name). Rendered using the ``query.html`` template.
|
||||
|
||||
``allow_execute_sql`` - ``bool``
|
||||
Boolean indicating if custom SQL can be executed
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for alternate JSON version of this page
|
||||
|
||||
``columns`` - ``list``
|
||||
List of result column names in the order they appear in ``display_rows`` and ``rows``.
|
||||
|
||||
``database`` - ``str``
|
||||
The name of the database being queried
|
||||
|
||||
``database_color`` - ``str``
|
||||
The color of the database
|
||||
|
||||
``db_is_immutable`` - ``bool``
|
||||
Boolean indicating if this database is immutable
|
||||
|
||||
``display_rows`` - ``list``
|
||||
List of result rows formatted for HTML display. Each row is a list of rendered cell values in the same order as ``columns``.
|
||||
|
||||
``edit_sql_url`` - ``str``
|
||||
URL to edit the SQL for a stored query
|
||||
|
||||
``editable`` - ``bool``
|
||||
Boolean indicating if the SQL can be edited
|
||||
|
||||
``error`` - ``str``
|
||||
Any query error message
|
||||
|
||||
``hide_sql`` - ``bool``
|
||||
Boolean indicating if the SQL should be hidden
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the database or stored query. Stored query metadata may include options such as ``hide_sql``, ``on_success_message`` and ``on_error_redirect``.
|
||||
|
||||
``named_parameter_values`` - ``dict``
|
||||
Dictionary of named SQL parameter values, keyed by parameter name without the leading ``:``.
|
||||
|
||||
``private`` - ``bool``
|
||||
Boolean indicating if this is a private database
|
||||
|
||||
``query`` - ``dict``
|
||||
Dictionary describing the SQL query being executed, with ``sql`` and ``params`` keys.
|
||||
|
||||
``query_actions`` - ``callable``
|
||||
Async callable returning action items for the query menu. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_query_actions`.
|
||||
|
||||
``renderers`` - ``dict``
|
||||
Dictionary mapping output format names such as ``json`` to URLs for this query in that format.
|
||||
|
||||
``save_query_url`` - ``str``
|
||||
URL to save the current arbitrary SQL as a query
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``show_hide_hidden`` - ``str``
|
||||
Rendered hidden ``<input>`` HTML preserving the current ``_hide_sql`` or ``_show_sql`` state.
|
||||
|
||||
``show_hide_link`` - ``str``
|
||||
The URL to toggle showing/hiding the SQL
|
||||
|
||||
``show_hide_text`` - ``str``
|
||||
The text for the show/hide SQL link
|
||||
|
||||
``stored_query`` - ``str``
|
||||
The name of the stored query if this is a stored query
|
||||
|
||||
``stored_query_write`` - ``bool``
|
||||
Boolean indicating if this is a stored query that allows writes
|
||||
|
||||
``table_columns`` - ``dict``
|
||||
Dictionary mapping table names to lists of column names, used to power SQL autocomplete.
|
||||
|
||||
``tables`` - ``list[DatabaseTable]``
|
||||
List of ``DatabaseTable`` objects describing tables in the database. Each item has ``name``, ``columns``, ``primary_keys``, ``count``, ``count_truncated``, ``hidden``, ``fts_table``, ``foreign_keys`` and ``private`` attributes. ``count_truncated`` is true if ``count`` is a capped lower bound rather than an exact total.
|
||||
|
||||
``top_query`` - ``callable``
|
||||
Async callable that renders the ``top_query`` plugin slot for this query and returns HTML.
|
||||
|
||||
``top_stored_query`` - ``callable``
|
||||
Async callable that renders the ``top_stored_query`` plugin slot for stored queries and returns HTML.
|
||||
|
||||
``url_csv`` - ``str``
|
||||
URL for CSV export
|
||||
|
||||
Table page
|
||||
----------
|
||||
|
||||
The page showing the rows in a table or SQL view, e.g. /fixtures/facetable. Rendered using the ``table.html`` template.
|
||||
|
||||
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
|
||||
|
||||
``actions`` - ``callable``
|
||||
Async callable returning table or view actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions`, :ref:`plugin_hook_table_actions` and :ref:`plugin_hook_view_actions`.
|
||||
|
||||
``all_columns`` - ``list``
|
||||
List of all column names in the table, regardless of ``_col=`` or ``_nocol=`` filtering.
|
||||
|
||||
``allow_execute_sql`` - ``bool``
|
||||
True if the current actor can execute custom SQL against this database
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for the JSON version of this page
|
||||
|
||||
``append_querystring`` - ``callable``
|
||||
Function ``append_querystring(url, querystring)`` that appends additional query string arguments to a URL, using ``?`` or ``&`` as appropriate.
|
||||
|
||||
``columns`` - ``list``
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``count`` - ``int``
|
||||
Total count of rows matching these filters
|
||||
|
||||
``count_sql`` - ``str``
|
||||
SQL query string used to calculate the total count for the current table view, including active filters.
|
||||
|
||||
``count_truncated`` - ``bool``
|
||||
True if ``count`` is a capped lower bound rather than an exact total, because Datasette stopped counting after its configured row-count limit.
|
||||
|
||||
``custom_table_templates`` - ``list``
|
||||
List of custom template names considered for rendering table rows, in lookup order.
|
||||
|
||||
``database`` - ``str``
|
||||
Database name
|
||||
|
||||
``database_color`` - ``str``
|
||||
Color assigned to the database
|
||||
|
||||
``datasette_allow_facet`` - ``str``
|
||||
The string "true" or "false" reflecting the allow_facet setting
|
||||
|
||||
``display_columns`` - ``list``
|
||||
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
|
||||
|
||||
``display_rows`` - ``list``
|
||||
Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys; table pages may also provide ``pk_path``, ``row_path`` and ``row_label`` attributes on each row object.
|
||||
|
||||
``expandable_columns`` - ``list``
|
||||
List of foreign key columns that can be expanded with labels. Each item is a ``(foreign_key, label_column)`` pair where ``foreign_key`` is the SQLite foreign key dictionary and ``label_column`` is the label column in the referenced table, or ``None``.
|
||||
|
||||
``extra_wheres_for_ui`` - ``list``
|
||||
Extra where clauses from ``?_where=`` for display in the UI. Each item has ``text`` for the SQL fragment and ``remove_url`` for a URL that removes that fragment.
|
||||
|
||||
``facet_results`` - ``dict``
|
||||
Results of facets calculated against this data. A dictionary with ``results`` and ``timed_out`` keys: ``results`` maps facet names to facet dictionaries with ``name``, ``type``, ``results`` and URL keys, and each facet result item includes ``value``, ``label``, ``count`` and ``toggle_url``.
|
||||
|
||||
``facets_timed_out`` - ``list``
|
||||
List of names of facet calculations that exceeded the facet time limit.
|
||||
|
||||
``filter_columns`` - ``list``
|
||||
List of column names offered by the filter interface, including currently displayed columns and any hidden columns that can still be filtered.
|
||||
|
||||
``filters`` - ``Filters``
|
||||
``Filters`` object used by the HTML table interface. Useful methods include ``filters.human_description_en()``; this is not JSON serializable.
|
||||
|
||||
``fix_path`` - ``callable``
|
||||
Function that applies the configured ``base_url`` prefix to a path.
|
||||
|
||||
``form_hidden_args`` - ``list``
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the HTML table interface to preserve current query string options.
|
||||
|
||||
``human_description_en`` - ``str``
|
||||
Human-readable description of the filters
|
||||
|
||||
``is_sortable`` - ``bool``
|
||||
True if any of the displayed columns can be used to sort
|
||||
|
||||
``is_view`` - ``bool``
|
||||
Whether this resource is a view instead of a table
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration.
|
||||
|
||||
``next`` - ``str``
|
||||
Pagination token for the next page, or None
|
||||
|
||||
``next_url`` - ``str``
|
||||
Full URL for the next page of results
|
||||
|
||||
``ok`` - ``bool``
|
||||
True if the data for this page was retrieved without errors
|
||||
|
||||
``path_with_replaced_args`` - ``callable``
|
||||
Function for building the current path with modified query string arguments. Pass the current ``request`` and a dictionary of argument names to replacement values, using ``None`` to remove an argument.
|
||||
|
||||
``primary_keys`` - ``list``
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``private`` - ``bool``
|
||||
Whether this resource is private to the current actor
|
||||
|
||||
``query`` - ``dict``
|
||||
Details of the underlying SQL query as a dictionary with ``sql`` and ``params`` keys.
|
||||
|
||||
``query_ms`` - ``float``
|
||||
Time taken by the SQL queries for this page, in milliseconds
|
||||
|
||||
``renderers`` - ``dict``
|
||||
Dictionary mapping output format names such as ``json`` or plugin-provided renderer names to URLs for this data in that format.
|
||||
|
||||
``rows`` - ``list``
|
||||
The rows for this page, as a list of dictionaries mapping column name to raw value.
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``set_column_type_ui`` - ``dict``
|
||||
Information needed to build an interface for assigning column types, or ``None`` if unavailable. When present it has ``path`` and ``columns`` keys; ``columns`` maps column names to ``current`` and ``options`` values.
|
||||
|
||||
``settings`` - ``dict``
|
||||
Dictionary of Datasette's current settings, keyed by setting name.
|
||||
|
||||
``sort`` - ``str``
|
||||
Column the page is sorted by, or None
|
||||
|
||||
``sort_desc`` - ``str``
|
||||
Column the page is sorted by in descending order, or None
|
||||
|
||||
``sorted_facet_results`` - ``list``
|
||||
Facet result dictionaries sorted for display. Each item has the same shape as an entry from ``facet_results['results']``.
|
||||
|
||||
``suggested_facets`` - ``list``
|
||||
Suggestions for facets that might return interesting results. Each item is a dictionary with ``name`` and ``toggle_url`` keys, and may include extra keys such as ``type`` or ``label`` depending on the facet class.
|
||||
|
||||
``supports_search`` - ``bool``
|
||||
True if this table has full-text search configured
|
||||
|
||||
``table`` - ``str``
|
||||
Table name
|
||||
|
||||
``table_alter_ui`` - ``dict``
|
||||
Information needed to enable the alter table UI, or ``None`` if altering this table is not available to the current actor. When present it has ``path``, ``tableName``, ``columns``, ``primaryKeys``, ``columnTypes``, ``defaultExpressions`` and ``foreignKeyTargetsPath`` keys, plus optional ``customColumnTypes`` and ``dropPath`` keys.
|
||||
|
||||
``table_definition`` - ``str``
|
||||
SQL definition for this table
|
||||
|
||||
``table_insert_ui`` - ``dict``
|
||||
Information needed to enable the row insertion UI, or ``None`` if row insertion is not available to the current actor. When present it has ``path``, ``tableName``, ``columns`` and ``primaryKeys`` keys; each column includes ``name``, ``sqlite_type``, ``notnull``, ``default``, ``has_default``, ``is_pk``, ``value_kind`` and ``column_type`` keys.
|
||||
|
||||
``table_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the table page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs, optional ``insertRow`` data and optional ``alterTable`` data.
|
||||
|
||||
``top_table`` - ``callable``
|
||||
Async callable that renders the ``top_table`` plugin slot for this table or view and returns HTML.
|
||||
|
||||
``url_csv`` - ``str``
|
||||
URL for the CSV export of this page
|
||||
|
||||
``url_csv_hidden_args`` - ``list``
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current filters while forcing ``_size=max``.
|
||||
|
||||
``url_csv_path`` - ``str``
|
||||
Path portion of the CSV export URL
|
||||
|
||||
``view_definition`` - ``str``
|
||||
SQL definition for this view
|
||||
|
||||
Row page
|
||||
--------
|
||||
|
||||
The page showing an individual row, e.g. /fixtures/facetable/1. Rendered using the ``row.html`` template.
|
||||
|
||||
Many of these keys are shared with the :ref:`JSON API <json_api>` for this page.
|
||||
|
||||
``alternate_url_json`` - ``str``
|
||||
URL for the JSON version of this page
|
||||
|
||||
``columns`` - ``list``
|
||||
List of column names returned by this table, row or query.
|
||||
|
||||
``custom_table_templates`` - ``list``
|
||||
Custom template names that were considered for displaying this row's table, in lookup order.
|
||||
|
||||
``database`` - ``str``
|
||||
Database name
|
||||
|
||||
``database_color`` - ``str``
|
||||
Color assigned to the database
|
||||
|
||||
``display_columns`` - ``list``
|
||||
Column metadata used by the HTML table display. Each item includes ``name``, ``sortable``, ``is_pk``, ``type``, ``notnull``, ``description``, ``column_type`` and ``column_type_config`` keys.
|
||||
|
||||
``display_rows`` - ``list``
|
||||
Rows formatted for the HTML table display. Each row is iterable and contains cell dictionaries with ``column``, ``value``, ``raw`` and ``value_type`` keys.
|
||||
|
||||
``foreign_key_tables`` - ``list``
|
||||
List of tables that link to this row using foreign keys. Each item includes the foreign key fields plus ``count`` for matching rows and ``link`` for the filtered table URL.
|
||||
|
||||
``metadata`` - ``dict``
|
||||
Metadata dictionary for the table, database or stored query. Table and row metadata include a ``columns`` dictionary mapping column names to descriptions; stored query metadata returns the stored query configuration.
|
||||
|
||||
``ok`` - ``bool``
|
||||
True if the data for this page was retrieved without errors
|
||||
|
||||
``primary_key_values`` - ``list``
|
||||
Values of the primary keys for this row, from the URL
|
||||
|
||||
``primary_keys`` - ``list``
|
||||
List of primary key column names for this table, or an empty list if the table has no explicit primary key.
|
||||
|
||||
``private`` - ``bool``
|
||||
Whether this resource is private to the current actor
|
||||
|
||||
``query_ms`` - ``float``
|
||||
Time taken by the SQL queries for this page, in milliseconds
|
||||
|
||||
``renderers`` - ``dict``
|
||||
Dictionary mapping output format names such as ``json`` to URLs for this row in that format.
|
||||
|
||||
``row_actions`` - ``list``
|
||||
Row actions made available by core and plugin hooks. Each item is either a link with ``href``, ``label`` and optional ``description`` keys, or a button with ``type: "button"``, ``label``, optional ``description`` and optional ``attrs``. See :ref:`plugin_actions` and :ref:`plugin_hook_row_actions`.
|
||||
|
||||
``row_mutation_ui`` - ``bool``
|
||||
True if the row edit/delete JavaScript UI should be enabled
|
||||
|
||||
``rows`` - ``list``
|
||||
A single-item list containing this row as a dictionary mapping column name to raw value.
|
||||
|
||||
``select_templates`` - ``list``
|
||||
List of template names that were considered for this page, with the selected template prefixed by ``*``.
|
||||
|
||||
``settings`` - ``dict``
|
||||
Dictionary of Datasette's current settings, keyed by setting name.
|
||||
|
||||
``table`` - ``str``
|
||||
Table name
|
||||
|
||||
``table_page_data`` - ``dict``
|
||||
JSON data used by JavaScript on the row page. Includes ``database``, ``table`` and ``tableUrl``, plus optional ``foreignKeys`` mapping column names to autocomplete URLs.
|
||||
|
||||
``top_row`` - ``callable``
|
||||
Async callable that renders the ``top_row`` plugin slot for this row and returns HTML.
|
||||
|
||||
``url_csv`` - ``str``
|
||||
URL for the CSV export of this page
|
||||
|
||||
``url_csv_hidden_args`` - ``list``
|
||||
List of ``(name, value)`` pairs for hidden form fields used by the CSV export form, preserving current options while forcing ``_size=max``.
|
||||
|
||||
``url_csv_path`` - ``str``
|
||||
Path portion of the CSV export URL
|
||||
|
||||
.. [[[end]]]
|
||||
45
docs/template_context_doc.py
Normal file
45
docs/template_context_doc.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""
|
||||
Cog helpers for generating docs/template_context.rst from the Context
|
||||
dataclasses and TEMPLATE_BASE_CONTEXT - same pattern as json_api_doc.py.
|
||||
"""
|
||||
|
||||
|
||||
def template_context(cog):
|
||||
from datasette.app import TEMPLATE_BASE_CONTEXT
|
||||
from datasette.template_contexts import PAGES
|
||||
|
||||
cog.out("\n")
|
||||
_section(
|
||||
cog,
|
||||
"Base context",
|
||||
(
|
||||
"These variables are available on every page rendered by "
|
||||
"Datasette, including pages rendered by plugins that use "
|
||||
":ref:`datasette.render_template() <datasette_render_template>`. "
|
||||
"Plugins can add additional variables using the "
|
||||
":ref:`plugin_hook_extra_template_vars` hook."
|
||||
),
|
||||
)
|
||||
for name, doc in TEMPLATE_BASE_CONTEXT.items():
|
||||
cog.out("``{}``\n".format(name))
|
||||
cog.out(" {}\n\n".format(doc))
|
||||
|
||||
for klass in PAGES.values():
|
||||
title = "{} page".format(klass.__name__.removesuffix("Context"))
|
||||
intro = "{} Rendered using the ``{}`` template.".format(
|
||||
klass.__doc__, klass.documented_template
|
||||
)
|
||||
_section(cog, title, intro)
|
||||
if klass.extras_scope is not None:
|
||||
cog.out(
|
||||
"Many of these keys are shared with the :ref:`JSON API "
|
||||
"<json_api>` for this page.\n\n"
|
||||
)
|
||||
for f in sorted(klass.documented_fields(), key=lambda f: f.name):
|
||||
cog.out("``{}`` - ``{}``\n".format(f.name, f.type_name))
|
||||
cog.out(" {}\n\n".format(f.help))
|
||||
|
||||
|
||||
def _section(cog, title, intro):
|
||||
cog.out("{}\n{}\n\n".format(title, "-" * len(title)))
|
||||
cog.out("{}\n\n".format(intro))
|
||||
|
|
@ -23,6 +23,38 @@ class DependentExtra(Extra):
|
|||
return slow_value + 1
|
||||
|
||||
|
||||
class InternalOnlyExtra(Extra):
|
||||
description = "Internal extra for HTML templates only"
|
||||
scopes = {ExtraScope.TABLE}
|
||||
public = False
|
||||
|
||||
async def resolve(self, context):
|
||||
return "internal"
|
||||
|
||||
|
||||
def test_internal_classes_for_scope():
|
||||
registry = ExtraRegistry([SlowValueExtra, DependentExtra, InternalOnlyExtra])
|
||||
assert registry.internal_classes_for_scope(ExtraScope.TABLE) == [InternalOnlyExtra]
|
||||
assert registry.public_classes_for_scope(ExtraScope.TABLE) == [
|
||||
SlowValueExtra,
|
||||
DependentExtra,
|
||||
]
|
||||
|
||||
|
||||
def _registered_extra_classes():
|
||||
# Plain Providers are internal dependency plumbing, only Extra
|
||||
# subclasses surface as documented JSON/template keys
|
||||
from datasette.views.table_extras import table_extra_registry
|
||||
|
||||
return [cls for cls in table_extra_registry.classes if issubclass(cls, Extra)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cls", _registered_extra_classes(), ids=lambda cls: cls.key())
|
||||
def test_registered_extras_have_descriptions(cls):
|
||||
# Every registered extra is part of the documented template/JSON contract
|
||||
assert cls.description, "{} is missing a description".format(cls.__name__)
|
||||
|
||||
|
||||
def test_registry_is_built_once_per_scope():
|
||||
registry = ExtraRegistry([SlowValueExtra, DependentExtra])
|
||||
first = registry._registry_for_scope(ExtraScope.TABLE)
|
||||
|
|
|
|||
|
|
@ -344,3 +344,28 @@ async def test_datasette_close_continues_past_db_error():
|
|||
ds.close()
|
||||
assert good._closed
|
||||
assert ds._internal_database._closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_datasette_render_template_dataclass_values_not_deep_copied():
|
||||
# display_rows can contain values like sqlite3.Row that cannot be
|
||||
# deep-copied, so render_template must convert Context dataclasses
|
||||
# shallowly - https://github.com/simonw/datasette/issues/2127
|
||||
class RefusesDeepCopy:
|
||||
def __deepcopy__(self, memo):
|
||||
raise RuntimeError("deepcopy not supported")
|
||||
|
||||
def __str__(self):
|
||||
return "shallow-copied-value"
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ExampleContext(Context):
|
||||
title: str
|
||||
status: int
|
||||
error: RefusesDeepCopy
|
||||
|
||||
context = ExampleContext(title="Hello", status=200, error=RefusesDeepCopy())
|
||||
ds = Datasette(memory=True)
|
||||
await ds.invoke_startup()
|
||||
rendered = await ds.render_template("error.html", context)
|
||||
assert "shallow-copied-value" in rendered
|
||||
|
|
|
|||
215
tests/test_template_context.py
Normal file
215
tests/test_template_context.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""
|
||||
Tests for the documented template context - the contract that custom
|
||||
template authors can rely on for Datasette 1.0.
|
||||
"""
|
||||
|
||||
import html
|
||||
import json
|
||||
import pathlib
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from datasette.app import Datasette, TEMPLATE_BASE_CONTEXT
|
||||
from datasette.extras import ExtraScope
|
||||
from datasette.fixtures import write_fixture_database
|
||||
from datasette.template_contexts import PAGES, documented_context_keys
|
||||
from datasette.views import Context
|
||||
|
||||
|
||||
def test_documented_fields():
|
||||
@dataclass
|
||||
class DemoNested:
|
||||
name: str
|
||||
|
||||
@dataclass
|
||||
class DemoContext(Context):
|
||||
name: str = field(metadata={"help": "The name"})
|
||||
_internal: str = field()
|
||||
count: int = field(metadata={"help": "How many there are"})
|
||||
items: list[DemoNested] = field(metadata={"help": "Nested items"})
|
||||
|
||||
fields = DemoContext.documented_fields()
|
||||
assert [(f.name, f.type_name, f.help) for f in fields] == [
|
||||
("name", "str", "The name"),
|
||||
("count", "int", "How many there are"),
|
||||
("items", "list[DemoNested]", "Nested items"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("klass", PAGES.values(), ids=lambda klass: klass.__name__)
|
||||
def test_context_class_fields_all_have_help(klass):
|
||||
for context_field in klass.documented_fields():
|
||||
assert context_field.help, "{}.{} is missing documentation".format(
|
||||
klass.__name__, context_field.name
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("klass", PAGES.values(), ids=lambda klass: klass.__name__)
|
||||
def test_context_class_has_docstring_and_documented_template(klass):
|
||||
assert klass.__doc__, "{} is missing a docstring".format(klass.__name__)
|
||||
assert klass.documented_template, "{} is missing a documented_template".format(
|
||||
klass.__name__
|
||||
)
|
||||
|
||||
|
||||
def test_from_extra_documentation_comes_from_the_extra_class():
|
||||
from datasette.views import from_extra
|
||||
from datasette.views.table_extras import CountExtra
|
||||
|
||||
@dataclass
|
||||
class DemoContext(Context):
|
||||
extras_scope = ExtraScope.TABLE
|
||||
|
||||
count: int = from_extra()
|
||||
name: str = field(metadata={"help": "The name"})
|
||||
|
||||
fields = {f.name: f for f in DemoContext.documented_fields()}
|
||||
assert fields["count"].help == CountExtra.description
|
||||
assert fields["count"].from_extra
|
||||
assert fields["name"].help == "The name"
|
||||
assert not fields["name"].from_extra
|
||||
|
||||
|
||||
def test_from_extra_must_match_a_registered_extra():
|
||||
from datasette.views import from_extra
|
||||
|
||||
@dataclass
|
||||
class BadContext(Context):
|
||||
extras_scope = ExtraScope.TABLE
|
||||
|
||||
not_a_real_extra: str = from_extra()
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
BadContext.documented_fields()
|
||||
|
||||
|
||||
def test_from_extra_must_be_available_for_the_scope():
|
||||
from datasette.views import from_extra
|
||||
|
||||
@dataclass
|
||||
class WrongScopeContext(Context):
|
||||
extras_scope = ExtraScope.ROW
|
||||
|
||||
# count is a TABLE-scope extra, not available for ROW
|
||||
count: int = from_extra()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
WrongScopeContext.documented_fields()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolate_extra_template_vars_plugins():
|
||||
# Datasette instances created with plugins_dir (e.g. the session-scoped
|
||||
# ds_client fixture) register their plugins on the global plugin manager
|
||||
# for the rest of the process. The contract documents plugin-free
|
||||
# Datasette core, so unregister any non-default plugin that adds
|
||||
# template variables via the extra_template_vars hook
|
||||
from datasette.plugins import pm, DEFAULT_PLUGINS
|
||||
|
||||
hook_plugins = {impl.plugin for impl in pm.hook.extra_template_vars.get_hookimpls()}
|
||||
removed = []
|
||||
for plugin in list(pm.get_plugins()):
|
||||
name = pm.get_name(plugin)
|
||||
if name not in DEFAULT_PLUGINS and plugin in hook_plugins:
|
||||
pm.unregister(plugin)
|
||||
removed.append((plugin, name))
|
||||
yield
|
||||
for plugin, name in removed:
|
||||
pm.register(plugin, name)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def context_ds(tmp_path_factory):
|
||||
db_path = tmp_path_factory.mktemp("template-context") / "fixtures.db"
|
||||
write_fixture_database(db_path)
|
||||
ds = Datasette(
|
||||
[str(db_path)],
|
||||
settings={"num_sql_threads": 1, "template_debug": True},
|
||||
config={
|
||||
"databases": {
|
||||
"fixtures": {
|
||||
"queries": {
|
||||
"neighborhood_search": {
|
||||
"sql": (
|
||||
"select _neighborhood from facetable "
|
||||
"where _neighborhood like '%' || :text || '%'"
|
||||
),
|
||||
"title": "Search neighborhoods",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
yield ds
|
||||
for db in ds.databases.values():
|
||||
if not db.is_memory:
|
||||
db.close()
|
||||
|
||||
|
||||
async def get_template_context(ds, path):
|
||||
sep = "&" if "?" in path else "?"
|
||||
response = await ds.client.get(path + sep + "_context=1")
|
||||
assert response.status_code == 200, path
|
||||
body = html.unescape(response.text.removeprefix("<pre>").removesuffix("</pre>"))
|
||||
return json.loads(body)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"page_name,path",
|
||||
(
|
||||
("database", "/fixtures"),
|
||||
("table", "/fixtures/facetable"),
|
||||
("table", "/fixtures/facetable?_city_id__exact=1"),
|
||||
("row", "/fixtures/facetable/1"),
|
||||
("query", "/fixtures/-/query?sql=select+*+from+facetable"),
|
||||
("query", "/fixtures/neighborhood_search?text=cork"),
|
||||
),
|
||||
)
|
||||
async def test_template_context_matches_documented_contract(
|
||||
context_ds, isolate_extra_template_vars_plugins, page_name, path
|
||||
):
|
||||
# The full contract: every key in the rendered template context is
|
||||
# documented, and every documented key is present in the context
|
||||
documented = documented_context_keys(page_name)
|
||||
actual = {
|
||||
key
|
||||
for key in await get_template_context(context_ds, path)
|
||||
if not key.startswith("_")
|
||||
}
|
||||
undocumented = actual - documented
|
||||
no_longer_present = documented - actual
|
||||
assert not undocumented, (
|
||||
"Undocumented keys in {} template context: {} - add them to the "
|
||||
"page's Context class".format(page_name, sorted(undocumented))
|
||||
)
|
||||
assert not no_longer_present, (
|
||||
"Documented keys missing from {} template context: {} - this would "
|
||||
"break custom templates".format(page_name, sorted(no_longer_present))
|
||||
)
|
||||
|
||||
|
||||
def test_base_context_keys_all_have_docs():
|
||||
for name, doc in TEMPLATE_BASE_CONTEXT.items():
|
||||
assert doc, "Base context key {} is missing docs".format(name)
|
||||
|
||||
|
||||
def test_template_context_docs_cover_every_documented_key():
|
||||
docs_path = pathlib.Path(__file__).parent.parent / "docs" / "template_context.rst"
|
||||
assert docs_path.exists(), "docs/template_context.rst is missing"
|
||||
docs = docs_path.read_text()
|
||||
for name in TEMPLATE_BASE_CONTEXT:
|
||||
assert "``{}``".format(name) in docs, name
|
||||
for page_name, klass in PAGES.items():
|
||||
title = "{} page".format(klass.__name__.removesuffix("Context"))
|
||||
assert title in docs, title
|
||||
for context_field in klass.documented_fields():
|
||||
assert "``{}``".format(context_field.name) in docs, "{} ({} page)".format(
|
||||
context_field.name, page_name
|
||||
)
|
||||
assert (
|
||||
"``{}`` - ``{}``".format(context_field.name, context_field.type_name)
|
||||
in docs
|
||||
), "{} type ({} page)".format(context_field.name, page_name)
|
||||
Loading…
Add table
Add a link
Reference in a new issue