kopia lustrzana https://github.com/simonw/datasette
Porównaj commity
32 Commity
Autor | SHA1 | Data |
---|---|---|
Simon Willison | 8f9509f00c | |
Simon Willison | 7d6d471dc5 | |
Simon Willison | 2a08ffed5c | |
Simon Willison | 63714cb2b7 | |
Simon Willison | d32176c5b8 | |
Simon Willison | 19b6a37336 | |
Simon Willison | 1edb24f124 | |
Simon Willison | da68662767 | |
Agustin Bacigalup | 67e66f36c1 | |
Simon Willison | 261fc8d875 | |
Simon Willison | eb8545c172 | |
Simon Willison | 54f5604caf | |
Simon Willison | 5af6837725 | |
Simon Willison | 8b6f155b45 | |
Simon Willison | c92f326ed1 | |
Simon Willison | feddd61789 | |
Simon Willison | 9cc6f1908f | |
Simon Willison | e088abdb46 | |
Simon Willison | 828ef9899f | |
Simon Willison | 8d456aae45 | |
Simon Willison | b8711988b9 | |
Simon Willison | 7339cc51de | |
Simon Willison | 06281a0b8e | |
Simon Willison | 909c85cd2b | |
Simon Willison | daf5ca02ca | |
Simon Willison | 7b32d5f7d8 | |
Simon Willison | 7818e8b9d1 | |
Simon Willison | a395256c8c | |
Simon Willison | 090dff542b | |
Simon Willison | c6e8a4a76c | |
Simon Willison | 4d24bf6b34 | |
Simon Willison | 5de6797d4a |
|
@ -930,7 +930,7 @@ class Datasette:
|
||||||
used_default = True
|
used_default = True
|
||||||
self._permission_checks.append(
|
self._permission_checks.append(
|
||||||
{
|
{
|
||||||
"when": datetime.datetime.utcnow().isoformat(),
|
"when": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
"actor": actor,
|
"actor": actor,
|
||||||
"action": action,
|
"action": action,
|
||||||
"resource": resource,
|
"resource": resource,
|
||||||
|
@ -1933,37 +1933,40 @@ class DatasetteClient:
|
||||||
path = f"http://localhost{path}"
|
path = f"http://localhost{path}"
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
async def _request(self, method, path, **kwargs):
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=self.app),
|
||||||
|
cookies=kwargs.pop("cookies", None),
|
||||||
|
) as client:
|
||||||
|
return await getattr(client, method)(self._fix(path), **kwargs)
|
||||||
|
|
||||||
async def get(self, path, **kwargs):
|
async def get(self, path, **kwargs):
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
return await self._request("get", path, **kwargs)
|
||||||
return await client.get(self._fix(path), **kwargs)
|
|
||||||
|
|
||||||
async def options(self, path, **kwargs):
|
async def options(self, path, **kwargs):
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
return await self._request("options", path, **kwargs)
|
||||||
return await client.options(self._fix(path), **kwargs)
|
|
||||||
|
|
||||||
async def head(self, path, **kwargs):
|
async def head(self, path, **kwargs):
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
return await self._request("head", path, **kwargs)
|
||||||
return await client.head(self._fix(path), **kwargs)
|
|
||||||
|
|
||||||
async def post(self, path, **kwargs):
|
async def post(self, path, **kwargs):
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
return await self._request("post", path, **kwargs)
|
||||||
return await client.post(self._fix(path), **kwargs)
|
|
||||||
|
|
||||||
async def put(self, path, **kwargs):
|
async def put(self, path, **kwargs):
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
return await self._request("put", path, **kwargs)
|
||||||
return await client.put(self._fix(path), **kwargs)
|
|
||||||
|
|
||||||
async def patch(self, path, **kwargs):
|
async def patch(self, path, **kwargs):
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
return await self._request("patch", path, **kwargs)
|
||||||
return await client.patch(self._fix(path), **kwargs)
|
|
||||||
|
|
||||||
async def delete(self, path, **kwargs):
|
async def delete(self, path, **kwargs):
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
return await self._request("delete", path, **kwargs)
|
||||||
return await client.delete(self._fix(path), **kwargs)
|
|
||||||
|
|
||||||
async def request(self, method, path, **kwargs):
|
async def request(self, method, path, **kwargs):
|
||||||
avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None)
|
avoid_path_rewrites = kwargs.pop("avoid_path_rewrites", None)
|
||||||
async with httpx.AsyncClient(app=self.app) as client:
|
async with httpx.AsyncClient(
|
||||||
|
transport=httpx.ASGITransport(app=self.app),
|
||||||
|
cookies=kwargs.pop("cookies", None),
|
||||||
|
) as client:
|
||||||
return await client.request(
|
return await client.request(
|
||||||
method, self._fix(path, avoid_path_rewrites), **kwargs
|
method, self._fix(path, avoid_path_rewrites), **kwargs
|
||||||
)
|
)
|
||||||
|
|
|
@ -469,6 +469,7 @@ class Database:
|
||||||
and (
|
and (
|
||||||
sql like '%VIRTUAL TABLE%USING FTS%'
|
sql like '%VIRTUAL TABLE%USING FTS%'
|
||||||
) or name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
) or name in ('sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4')
|
||||||
|
or name like '\\_%' escape '\\'
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
).rows
|
).rows
|
||||||
|
|
|
@ -24,9 +24,12 @@ def now(key, request):
|
||||||
if key == "epoch":
|
if key == "epoch":
|
||||||
return int(time.time())
|
return int(time.time())
|
||||||
elif key == "date_utc":
|
elif key == "date_utc":
|
||||||
return datetime.datetime.utcnow().date().isoformat()
|
return datetime.datetime.now(datetime.timezone.utc).date().isoformat()
|
||||||
elif key == "datetime_utc":
|
elif key == "datetime_utc":
|
||||||
return datetime.datetime.utcnow().strftime(r"%Y-%m-%dT%H:%M:%S") + "Z"
|
return (
|
||||||
|
datetime.datetime.now(datetime.timezone.utc).strftime(r"%Y-%m-%dT%H:%M:%S")
|
||||||
|
+ "Z"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise KeyError
|
raise KeyError
|
||||||
|
|
||||||
|
|
|
@ -140,11 +140,21 @@ def menu_links(datasette, actor, request):
|
||||||
"""Links for the navigation menu"""
|
"""Links for the navigation menu"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def row_actions(datasette, actor, request, database, table, row):
|
||||||
|
"""Links for the row actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def table_actions(datasette, actor, database, table, request):
|
def table_actions(datasette, actor, database, table, request):
|
||||||
"""Links for the table actions menu"""
|
"""Links for the table actions menu"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def view_actions(datasette, actor, database, view, request):
|
||||||
|
"""Links for the view actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
def query_actions(datasette, actor, database, query_name, request, sql, params):
|
||||||
"""Links for the query and canned query actions menu"""
|
"""Links for the query and canned query actions menu"""
|
||||||
|
@ -155,6 +165,11 @@ def database_actions(datasette, actor, database, request):
|
||||||
"""Links for the database actions menu"""
|
"""Links for the database actions menu"""
|
||||||
|
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def homepage_actions(datasette, actor, request):
|
||||||
|
"""Links for the homepage actions menu"""
|
||||||
|
|
||||||
|
|
||||||
@hookspec
|
@hookspec
|
||||||
def skip_csrf(datasette, scope):
|
def skip_csrf(datasette, scope):
|
||||||
"""Mechanism for skipping CSRF checks for certain requests"""
|
"""Mechanism for skipping CSRF checks for certain requests"""
|
||||||
|
|
|
@ -269,6 +269,7 @@ header,
|
||||||
footer {
|
footer {
|
||||||
padding: 0.6rem 1rem 0.5rem 1rem;
|
padding: 0.6rem 1rem 0.5rem 1rem;
|
||||||
background-color: #276890;
|
background-color: #276890;
|
||||||
|
background: linear-gradient(180deg, rgba(96,144,173,1) 0%, rgba(39,104,144,1) 50%);
|
||||||
color: rgba(255,255,244,0.9);
|
color: rgba(255,255,244,0.9);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
@ -346,12 +347,17 @@ details.nav-menu > summary::-webkit-details-marker {
|
||||||
}
|
}
|
||||||
details .nav-menu-inner {
|
details .nav-menu-inner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2rem;
|
top: 2.6rem;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
background-color: #276890;
|
background-color: #276890;
|
||||||
padding: 1rem;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.nav-menu-inner li,
|
||||||
|
form.nav-menu-logout {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-top: 1px solid #ffffff69;
|
||||||
}
|
}
|
||||||
.nav-menu-inner a {
|
.nav-menu-inner a {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -360,6 +366,7 @@ details .nav-menu-inner {
|
||||||
/* Table/database actions menu */
|
/* Table/database actions menu */
|
||||||
.page-action-menu {
|
.page-action-menu {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
.actions-menu-links {
|
.actions-menu-links {
|
||||||
display: inline;
|
display: inline;
|
||||||
|
@ -368,6 +375,7 @@ details .nav-menu-inner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 10px);
|
top: calc(100% + 10px);
|
||||||
left: 0;
|
left: 0;
|
||||||
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
.page-action-menu .icon-text {
|
.page-action-menu .icon-text {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
@ -840,6 +848,13 @@ svg.dropdown-menu-icon {
|
||||||
.dropdown-menu a:hover {
|
.dropdown-menu a:hover {
|
||||||
background-color: #eee;
|
background-color: #eee;
|
||||||
}
|
}
|
||||||
|
.dropdown-menu .dropdown-description {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.8em;
|
||||||
|
max-width: 80vw;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
.dropdown-menu .hook {
|
.dropdown-menu .hook {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
{% if action_links %}
|
||||||
|
<div class="page-action-menu">
|
||||||
|
<details class="actions-menu-links details-menu">
|
||||||
|
<summary>
|
||||||
|
<div class="icon-text">
|
||||||
|
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<title id="actions-menu-links-title">{{ action_title }}</title>
|
||||||
|
<circle cx="12" cy="12" r="3"></circle>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ action_title }}</span>
|
||||||
|
</div>
|
||||||
|
</summary>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<div class="hook"></div>
|
||||||
|
<ul>
|
||||||
|
{% for link in action_links %}
|
||||||
|
<li><a href="{{ link.href }}">{{ link.label }}
|
||||||
|
{% if link.description %}
|
||||||
|
<p class="dropdown-description">{{ link.description }}</p>
|
||||||
|
{% endif %}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
|
@ -37,7 +37,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if show_logout %}
|
{% if show_logout %}
|
||||||
<form action="{{ urls.logout() }}" method="post">
|
<form class="nav-menu-logout" action="{{ urls.logout() }}" method="post">
|
||||||
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
<input type="hidden" name="csrftoken" value="{{ csrftoken() }}">
|
||||||
<button class="button-as-link">Log out</button>
|
<button class="button-as-link">Log out</button>
|
||||||
</form>{% endif %}
|
</form>{% endif %}
|
||||||
|
|
|
@ -13,33 +13,8 @@
|
||||||
<div class="page-header" style="border-color: #{{ database_color }}">
|
<div class="page-header" style="border-color: #{{ database_color }}">
|
||||||
<h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
|
<h1>{{ metadata.title or database }}{% if private %} 🔒{% endif %}</h1>
|
||||||
</div>
|
</div>
|
||||||
{% set links = database_actions() %}{% if links %}
|
{% set action_links, action_title = database_actions(), "Database actions" %}
|
||||||
<div class="page-action-menu">
|
{% include "_action_menu.html" %}
|
||||||
<details class="actions-menu-links details-menu">
|
|
||||||
<summary>
|
|
||||||
<div class="icon-text">
|
|
||||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<title id="actions-menu-links-title">Database actions</title>
|
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Database actions</span>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<div class="hook"></div>
|
|
||||||
{% if links %}
|
|
||||||
<ul>
|
|
||||||
{% for link in links %}
|
|
||||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{{ top_database() }}
|
{{ top_database() }}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}</h1>
|
<h1>{{ metadata.title or "Datasette" }}{% if private %} 🔒{% endif %}</h1>
|
||||||
|
|
||||||
|
{% set action_links, action_title = homepage_actions, "Homepage actions" %}
|
||||||
|
{% include "_action_menu.html" %}
|
||||||
|
|
||||||
{{ top_homepage() }}
|
{{ top_homepage() }}
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
<li><a href="/-/plugins">Installed plugins</a></li>
|
<li><a href="/-/plugins">Installed plugins</a></li>
|
||||||
<li><a href="/-/versions">Version info</a></li>
|
<li><a href="/-/versions">Version info</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<form action="/-/logout" method="post">
|
<form class="nav-menu-logout" action="/-/logout" method="post">
|
||||||
<button class="button-as-link">Log out</button>
|
<button class="button-as-link">Log out</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -29,33 +29,8 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ metadata.title or database }}{% if canned_query and not metadata.title %}: {{ canned_query }}{% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||||
{% set links = query_actions() %}{% if links %}
|
{% set action_links, action_title = query_actions(), "Query actions" %}
|
||||||
<div class="page-action-menu">
|
{% include "_action_menu.html" %}
|
||||||
<details class="actions-menu-links details-menu">
|
|
||||||
<summary>
|
|
||||||
<div class="icon-text">
|
|
||||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<title id="actions-menu-links-title">Query actions</title>
|
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Query actions</span>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<div class="hook"></div>
|
|
||||||
{% if links %}
|
|
||||||
<ul>
|
|
||||||
{% for link in links %}
|
|
||||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
|
|
||||||
{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
|
{% if canned_query %}{{ top_canned_query() }}{% else %}{{ top_query() }}{% endif %}
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,9 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}</h1>
|
<h1 style="padding-left: 10px; border-left: 10px solid #{{ database_color }}">{{ table }}: {{ ', '.join(primary_key_values) }}{% if private %} 🔒{% endif %}</h1>
|
||||||
|
|
||||||
|
{% set action_links, action_title = row_actions, "Row actions" %}
|
||||||
|
{% include "_action_menu.html" %}
|
||||||
|
|
||||||
{{ top_row() }}
|
{{ top_row() }}
|
||||||
|
|
||||||
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
{% block description_source_license %}{% include "_description_source_license.html" %}{% endblock %}
|
||||||
|
|
|
@ -24,32 +24,8 @@
|
||||||
<div class="page-header" style="border-color: #{{ database_color }}">
|
<div class="page-header" style="border-color: #{{ database_color }}">
|
||||||
<h1>{{ metadata.get("title") or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
|
<h1>{{ metadata.get("title") or table }}{% if is_view %} (view){% endif %}{% if private %} 🔒{% endif %}</h1>
|
||||||
</div>
|
</div>
|
||||||
{% set links = table_actions() %}{% if links %}
|
{% set action_links, action_title = actions(), "View actions" if is_view else "Table actions" %}
|
||||||
<div class="page-action-menu">
|
{% include "_action_menu.html" %}
|
||||||
<details class="actions-menu-links details-menu">
|
|
||||||
<summary>
|
|
||||||
<div class="icon-text">
|
|
||||||
<svg class="icon" aria-labelledby="actions-menu-links-title" role="img" style="color: #fff" xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<title id="actions-menu-links-title">Table actions</title>
|
|
||||||
<circle cx="12" cy="12" r="3"></circle>
|
|
||||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Table actions</span>
|
|
||||||
</div>
|
|
||||||
</summary>
|
|
||||||
<div class="dropdown-menu">
|
|
||||||
<div class="hook"></div>
|
|
||||||
{% if links %}
|
|
||||||
<ul>
|
|
||||||
{% for link in links %}
|
|
||||||
<li><a href="{{ link.href }}">{{ link.label }}</a></li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{{ top_table() }}
|
{{ top_table() }}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
import aiofiles
|
||||||
import click
|
import click
|
||||||
from collections import OrderedDict, namedtuple, Counter
|
from collections import OrderedDict, namedtuple, Counter
|
||||||
import copy
|
import copy
|
||||||
|
@ -246,6 +247,7 @@ allowed_pragmas = (
|
||||||
"schema_version",
|
"schema_version",
|
||||||
"table_info",
|
"table_info",
|
||||||
"table_xinfo",
|
"table_xinfo",
|
||||||
|
"table_list",
|
||||||
)
|
)
|
||||||
disallawed_sql_res = [
|
disallawed_sql_res = [
|
||||||
(
|
(
|
||||||
|
@ -1417,3 +1419,24 @@ def md5_not_usedforsecurity(s):
|
||||||
except TypeError:
|
except TypeError:
|
||||||
# For Python 3.8 which does not support usedforsecurity=False
|
# For Python 3.8 which does not support usedforsecurity=False
|
||||||
return hashlib.md5(s.encode("utf8")).hexdigest()
|
return hashlib.md5(s.encode("utf8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
_etag_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def calculate_etag(filepath, chunk_size=4096):
|
||||||
|
if filepath in _etag_cache:
|
||||||
|
return _etag_cache[filepath]
|
||||||
|
|
||||||
|
hasher = hashlib.md5()
|
||||||
|
async with aiofiles.open(filepath, "rb") as f:
|
||||||
|
while True:
|
||||||
|
chunk = await f.read(chunk_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
hasher.update(chunk)
|
||||||
|
|
||||||
|
etag = f'"{hasher.hexdigest()}"'
|
||||||
|
_etag_cache[filepath] = etag
|
||||||
|
|
||||||
|
return etag
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from datasette.utils import MultiParams
|
from datasette.utils import MultiParams, calculate_etag
|
||||||
from mimetypes import guess_type
|
from mimetypes import guess_type
|
||||||
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
from urllib.parse import parse_qs, urlunparse, parse_qsl
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
@ -285,6 +286,7 @@ async def asgi_send_file(
|
||||||
headers = headers or {}
|
headers = headers or {}
|
||||||
if filename:
|
if filename:
|
||||||
headers["content-disposition"] = f'attachment; filename="{filename}"'
|
headers["content-disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
first = True
|
first = True
|
||||||
headers["content-length"] = str((await aiofiles.os.stat(str(filepath))).st_size)
|
headers["content-length"] = str((await aiofiles.os.stat(str(filepath))).st_size)
|
||||||
async with aiofiles.open(str(filepath), mode="rb") as fp:
|
async with aiofiles.open(str(filepath), mode="rb") as fp:
|
||||||
|
@ -307,9 +309,14 @@ async def asgi_send_file(
|
||||||
|
|
||||||
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
root_path = Path(root_path)
|
root_path = Path(root_path)
|
||||||
|
static_headers = {}
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
static_headers = headers.copy()
|
||||||
|
|
||||||
async def inner_static(request, send):
|
async def inner_static(request, send):
|
||||||
path = request.scope["url_route"]["kwargs"]["path"]
|
path = request.scope["url_route"]["kwargs"]["path"]
|
||||||
|
headers = static_headers.copy()
|
||||||
try:
|
try:
|
||||||
full_path = (root_path / path).resolve().absolute()
|
full_path = (root_path / path).resolve().absolute()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
|
@ -325,7 +332,15 @@ def asgi_static(root_path, chunk_size=4096, headers=None, content_type=None):
|
||||||
await asgi_send_html(send, "404: Path not inside root path", 404)
|
await asgi_send_html(send, "404: Path not inside root path", 404)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await asgi_send_file(send, full_path, chunk_size=chunk_size)
|
# Calculate ETag for filepath
|
||||||
|
etag = await calculate_etag(full_path, chunk_size=chunk_size)
|
||||||
|
headers["ETag"] = etag
|
||||||
|
if_none_match = request.headers.get("if-none-match")
|
||||||
|
if if_none_match and if_none_match == etag:
|
||||||
|
return await asgi_send(send, "", 304)
|
||||||
|
await asgi_send_file(
|
||||||
|
send, full_path, chunk_size=chunk_size, headers=headers
|
||||||
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
await asgi_send_html(send, "404: File not found", 404)
|
await asgi_send_html(send, "404: File not found", 404)
|
||||||
return
|
return
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
__version__ = "1.0a12"
|
__version__ = "1.0a13"
|
||||||
__version_info__ = tuple(__version__.split("."))
|
__version_info__ = tuple(__version__.split("."))
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from datasette.utils import add_cors_headers, make_slot_function, CustomJSONEncoder
|
from datasette.plugins import pm
|
||||||
|
from datasette.utils import (
|
||||||
|
add_cors_headers,
|
||||||
|
await_me_maybe,
|
||||||
|
make_slot_function,
|
||||||
|
CustomJSONEncoder,
|
||||||
|
)
|
||||||
from datasette.utils.asgi import Response
|
from datasette.utils.asgi import Response
|
||||||
from datasette.version import __version__
|
from datasette.version import __version__
|
||||||
|
|
||||||
|
@ -131,6 +137,15 @@ class IndexView(BaseView):
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
homepage_actions = []
|
||||||
|
for hook in pm.hook.homepage_actions(
|
||||||
|
datasette=self.ds,
|
||||||
|
actor=request.actor,
|
||||||
|
request=request,
|
||||||
|
):
|
||||||
|
extra_links = await await_me_maybe(hook)
|
||||||
|
if extra_links:
|
||||||
|
homepage_actions.extend(extra_links)
|
||||||
return await self.render(
|
return await self.render(
|
||||||
["index.html"],
|
["index.html"],
|
||||||
request=request,
|
request=request,
|
||||||
|
@ -144,5 +159,6 @@ class IndexView(BaseView):
|
||||||
"top_homepage": make_slot_function(
|
"top_homepage": make_slot_function(
|
||||||
"top_homepage", self.ds, request
|
"top_homepage", self.ds, request
|
||||||
),
|
),
|
||||||
|
"homepage_actions": homepage_actions,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,10 +3,12 @@ from datasette.database import QueryInterrupted
|
||||||
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
from datasette.events import UpdateRowEvent, DeleteRowEvent
|
||||||
from .base import DataView, BaseView, _error
|
from .base import DataView, BaseView, _error
|
||||||
from datasette.utils import (
|
from datasette.utils import (
|
||||||
|
await_me_maybe,
|
||||||
make_slot_function,
|
make_slot_function,
|
||||||
to_css_class,
|
to_css_class,
|
||||||
escape_sqlite,
|
escape_sqlite,
|
||||||
)
|
)
|
||||||
|
from datasette.plugins import pm
|
||||||
import json
|
import json
|
||||||
import sqlite_utils
|
import sqlite_utils
|
||||||
from .table import display_columns_and_rows
|
from .table import display_columns_and_rows
|
||||||
|
@ -55,6 +57,20 @@ class RowView(DataView):
|
||||||
)
|
)
|
||||||
for column in display_columns:
|
for column in display_columns:
|
||||||
column["sortable"] = False
|
column["sortable"] = False
|
||||||
|
|
||||||
|
row_actions = []
|
||||||
|
for hook in pm.hook.row_actions(
|
||||||
|
datasette=self.ds,
|
||||||
|
actor=request.actor,
|
||||||
|
request=request,
|
||||||
|
database=database,
|
||||||
|
table=table,
|
||||||
|
row=rows[0],
|
||||||
|
):
|
||||||
|
extra_links = await await_me_maybe(hook)
|
||||||
|
if extra_links:
|
||||||
|
row_actions.extend(extra_links)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"private": private,
|
"private": private,
|
||||||
"foreign_key_tables": await self.foreign_key_tables(
|
"foreign_key_tables": await self.foreign_key_tables(
|
||||||
|
@ -68,6 +84,7 @@ class RowView(DataView):
|
||||||
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
|
f"_table-row-{to_css_class(database)}-{to_css_class(table)}.html",
|
||||||
"_table.html",
|
"_table.html",
|
||||||
],
|
],
|
||||||
|
"row_actions": row_actions,
|
||||||
"metadata": (self.ds.metadata("databases") or {})
|
"metadata": (self.ds.metadata("databases") or {})
|
||||||
.get(database, {})
|
.get(database, {})
|
||||||
.get("tables", {})
|
.get("tables", {})
|
||||||
|
|
|
@ -1401,22 +1401,28 @@ async def table_view_data(
|
||||||
"Primary keys for this table"
|
"Primary keys for this table"
|
||||||
return pks
|
return pks
|
||||||
|
|
||||||
async def extra_table_actions():
|
async def extra_actions():
|
||||||
async def table_actions():
|
async def actions():
|
||||||
links = []
|
links = []
|
||||||
for hook in pm.hook.table_actions(
|
kwargs = {
|
||||||
datasette=datasette,
|
"datasette": datasette,
|
||||||
table=table_name,
|
"database": database_name,
|
||||||
database=database_name,
|
"actor": request.actor,
|
||||||
actor=request.actor,
|
"request": request,
|
||||||
request=request,
|
}
|
||||||
):
|
if is_view:
|
||||||
|
kwargs["view"] = table_name
|
||||||
|
method = pm.hook.view_actions
|
||||||
|
else:
|
||||||
|
kwargs["table"] = table_name
|
||||||
|
method = pm.hook.table_actions
|
||||||
|
for hook in method(**kwargs):
|
||||||
extra_links = await await_me_maybe(hook)
|
extra_links = await await_me_maybe(hook)
|
||||||
if extra_links:
|
if extra_links:
|
||||||
links.extend(extra_links)
|
links.extend(extra_links)
|
||||||
return links
|
return links
|
||||||
|
|
||||||
return table_actions
|
return actions
|
||||||
|
|
||||||
async def extra_is_view():
|
async def extra_is_view():
|
||||||
return is_view
|
return is_view
|
||||||
|
@ -1606,7 +1612,7 @@ async def table_view_data(
|
||||||
"database",
|
"database",
|
||||||
"table",
|
"table",
|
||||||
"database_color",
|
"database_color",
|
||||||
"table_actions",
|
"actions",
|
||||||
"filters",
|
"filters",
|
||||||
"renderers",
|
"renderers",
|
||||||
"custom_table_templates",
|
"custom_table_templates",
|
||||||
|
@ -1647,7 +1653,7 @@ async def table_view_data(
|
||||||
extra_database,
|
extra_database,
|
||||||
extra_table,
|
extra_table,
|
||||||
extra_database_color,
|
extra_database_color,
|
||||||
extra_table_actions,
|
extra_actions,
|
||||||
extra_filters,
|
extra_filters,
|
||||||
extra_renderers,
|
extra_renderers,
|
||||||
extra_custom_table_templates,
|
extra_custom_table_templates,
|
||||||
|
|
|
@ -312,7 +312,7 @@ If you want to provide access to any actor with a value for a specific key, use
|
||||||
}
|
}
|
||||||
.. [[[end]]]
|
.. [[[end]]]
|
||||||
|
|
||||||
You can specify that only unauthenticated actors (from anynomous HTTP requests) should be allowed access using the special ``"unauthenticated": true`` key in an allow block (`allow demo <https://latest.datasette.io/-/allow-debug?actor=null&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22hello%22%0D%0A%7D&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__):
|
You can specify that only unauthenticated actors (from anonymous HTTP requests) should be allowed access using the special ``"unauthenticated": true`` key in an allow block (`allow demo <https://latest.datasette.io/-/allow-debug?actor=null&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__, `deny demo <https://latest.datasette.io/-/allow-debug?actor=%7B%0D%0A++++%22id%22%3A+%22hello%22%0D%0A%7D&allow=%7B%0D%0A++++%22unauthenticated%22%3A+true%0D%0A%7D>`__):
|
||||||
|
|
||||||
.. [[[cog
|
.. [[[cog
|
||||||
from metadata_doc import config_example
|
from metadata_doc import config_example
|
||||||
|
|
|
@ -4,6 +4,23 @@
|
||||||
Changelog
|
Changelog
|
||||||
=========
|
=========
|
||||||
|
|
||||||
|
.. _v1_0_a13:
|
||||||
|
|
||||||
|
1.0a13 (2024-03-12)
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Each of the key concepts in Datasette now has an :ref:`actions menu <plugin_actions>`, which plugins can use to add additional functionality targeting that entity.
|
||||||
|
|
||||||
|
- Plugin hook: :ref:`view_actions() <plugin_hook_view_actions>` for actions that can be applied to a SQL view. (:issue:`2297`)
|
||||||
|
- Plugin hook: :ref:`homepage_actions() <plugin_hook_homepage_actions>` for actions that apply to the instance homepage. (:issue:`2298`)
|
||||||
|
- Plugin hook: :ref:`row_actions() <plugin_hook_row_actions>` for actions that apply to the row page. (:issue:`2299`)
|
||||||
|
- Action menu items for all of the ``*_actions()`` plugin hooks can now return an optional ``"description"`` key, which will be displayed in the menu below the action label. (:issue:`2294`)
|
||||||
|
- :ref:`Plugin hooks <plugin_hooks>` documentation page is now organized with additional headings. (:issue:`2300`)
|
||||||
|
- Improved the display of action buttons on pages that also display metadata. (:issue:`2286`)
|
||||||
|
- The header and footer of the page now uses a subtle gradient effect, and options in the navigation menu are better visually defined. (:issue:`2302`)
|
||||||
|
- Table names that start with an underscore now default to hidden. (:issue:`2104`)
|
||||||
|
- ``pragma_table_list`` has been added to the allow-list of SQLite pragma functions supported by Datasette. ``select * from pragma_table_list()`` is no longer blocked. (`#2104 <https://github.com/simonw/datasette/issues/2104#issuecomment-1982352475>`__)
|
||||||
|
|
||||||
.. _v1_0_a12:
|
.. _v1_0_a12:
|
||||||
|
|
||||||
1.0a12 (2024-02-29)
|
1.0a12 (2024-02-29)
|
||||||
|
|
|
@ -386,7 +386,7 @@ This is useful when you need to check multiple permissions at once. For example,
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
await self.ds.ensure_permissions(
|
await datasette.ensure_permissions(
|
||||||
request.actor,
|
request.actor,
|
||||||
[
|
[
|
||||||
("view-table", (database, table)),
|
("view-table", (database, table)),
|
||||||
|
@ -420,7 +420,7 @@ This example checks if the user can access a specific table, and sets ``private`
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
visible, private = await self.ds.check_visibility(
|
visible, private = await datasette.check_visibility(
|
||||||
request.actor,
|
request.actor,
|
||||||
action="view-table",
|
action="view-table",
|
||||||
resource=(database, table),
|
resource=(database, table),
|
||||||
|
@ -430,7 +430,7 @@ The following example runs three checks in a row, similar to :ref:`datasette_ens
|
||||||
|
|
||||||
.. code-block:: python
|
.. code-block:: python
|
||||||
|
|
||||||
visible, private = await self.ds.check_visibility(
|
visible, private = await datasette.check_visibility(
|
||||||
request.actor,
|
request.actor,
|
||||||
permissions=[
|
permissions=[
|
||||||
("view-table", (database, table)),
|
("view-table", (database, table)),
|
||||||
|
@ -1222,7 +1222,7 @@ Plugins can access this database by calling ``internal_db = datasette.get_intern
|
||||||
|
|
||||||
Plugin authors are asked to practice good etiquette when using the internal database, as all plugins use the same database to store data. For example:
|
Plugin authors are asked to practice good etiquette when using the internal database, as all plugins use the same database to store data. For example:
|
||||||
|
|
||||||
1. Use a unique prefix when creating tables, indices, and triggera in the internal database. If your plugin is called ``datasette-xyz``, then prefix names with ``datasette_xyz_*``.
|
1. Use a unique prefix when creating tables, indices, and triggers in the internal database. If your plugin is called ``datasette-xyz``, then prefix names with ``datasette_xyz_*``.
|
||||||
2. Avoid long-running write statements that may stall or block other plugins that are trying to write at the same time.
|
2. Avoid long-running write statements that may stall or block other plugins that are trying to write at the same time.
|
||||||
3. Use temporary tables or shared in-memory attached databases when possible.
|
3. Use temporary tables or shared in-memory attached databases when possible.
|
||||||
4. Avoid implementing features that could expose private data stored in the internal database by other plugins.
|
4. Avoid implementing features that could expose private data stored in the internal database by other plugins.
|
||||||
|
@ -1234,7 +1234,7 @@ The datasette.utils module
|
||||||
|
|
||||||
The ``datasette.utils`` module contains various utility functions used by Datasette. As a general rule you should consider anything in this module to be unstable - functions and classes here could change without warning or be removed entirely between Datasette releases, without being mentioned in the release notes.
|
The ``datasette.utils`` module contains various utility functions used by Datasette. As a general rule you should consider anything in this module to be unstable - functions and classes here could change without warning or be removed entirely between Datasette releases, without being mentioned in the release notes.
|
||||||
|
|
||||||
The exception to this rule is anythang that is documented here. If you find a need for an undocumented utility function in your own work, consider `opening an issue <https://github.com/simonw/datasette/issues/new>`__ requesting that the function you are using be upgraded to documented and supported status.
|
The exception to this rule is anything that is documented here. If you find a need for an undocumented utility function in your own work, consider `opening an issue <https://github.com/simonw/datasette/issues/new>`__ requesting that the function you are using be upgraded to documented and supported status.
|
||||||
|
|
||||||
.. _internals_utils_parse_metadata:
|
.. _internals_utils_parse_metadata:
|
||||||
|
|
||||||
|
|
|
@ -568,6 +568,8 @@ To insert multiple rows at a time, use the same API method but send a list of di
|
||||||
|
|
||||||
If successful, this will return a ``201`` status code and a ``{"ok": true}`` response body.
|
If successful, this will return a ``201`` status code and a ``{"ok": true}`` response body.
|
||||||
|
|
||||||
|
The maximum number rows that can be submitted at once defaults to 100, but this can be changed using the :ref:`setting_max_insert_rows` setting.
|
||||||
|
|
||||||
To return the newly inserted rows, add the ``"return": true`` key to the request body:
|
To return the newly inserted rows, add the ``"return": true`` key to the request body:
|
||||||
|
|
||||||
.. code-block:: json
|
.. code-block:: json
|
||||||
|
|
|
@ -40,6 +40,21 @@ The JSON version of this page provides programmatic access to the underlying dat
|
||||||
* `fivethirtyeight.datasettes.com/fivethirtyeight.json <https://fivethirtyeight.datasettes.com/fivethirtyeight.json>`_
|
* `fivethirtyeight.datasettes.com/fivethirtyeight.json <https://fivethirtyeight.datasettes.com/fivethirtyeight.json>`_
|
||||||
* `global-power-plants.datasettes.com/global-power-plants.json <https://global-power-plants.datasettes.com/global-power-plants.json>`_
|
* `global-power-plants.datasettes.com/global-power-plants.json <https://global-power-plants.datasettes.com/global-power-plants.json>`_
|
||||||
|
|
||||||
|
.. _DatabaseView_hidden:
|
||||||
|
|
||||||
|
Hidden tables
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Some tables listed on the database page are treated as hidden. Hidden tables are not completely invisible - they can be accessed through the "hidden tables" link at the bottom of the page. They are hidden because they represent low-level implementation details which are generally not useful to end-users of Datasette.
|
||||||
|
|
||||||
|
The following tables are hidden by default:
|
||||||
|
|
||||||
|
- Any table with a name that starts with an underscore - this is a Datasette convention to help plugins easily hide their own internal tables.
|
||||||
|
- Tables that have been configured as ``"hidden": true`` using :ref:`metadata_hiding_tables`.
|
||||||
|
- ``*_fts`` tables that implement SQLite full-text search indexes.
|
||||||
|
- Tables relating to the inner workings of the SpatiaLite SQLite extension.
|
||||||
|
- ``sqlite_stat`` tables used to store statistics used by the query optimizer.
|
||||||
|
|
||||||
.. _TableView:
|
.. _TableView:
|
||||||
|
|
||||||
Table
|
Table
|
||||||
|
|
|
@ -92,10 +92,17 @@ This function can return an awaitable function if it needs to run any async code
|
||||||
|
|
||||||
Examples: `datasette-edit-templates <https://datasette.io/plugins/datasette-edit-templates>`_
|
Examples: `datasette-edit-templates <https://datasette.io/plugins/datasette-edit-templates>`_
|
||||||
|
|
||||||
|
.. _plugin_page_extras:
|
||||||
|
|
||||||
|
Page extras
|
||||||
|
-----------
|
||||||
|
|
||||||
|
These plugin hooks can be used to affect the way HTML pages for different Datasette interfaces are rendered.
|
||||||
|
|
||||||
.. _plugin_hook_extra_template_vars:
|
.. _plugin_hook_extra_template_vars:
|
||||||
|
|
||||||
extra_template_vars(template, database, table, columns, view_name, request, datasette)
|
extra_template_vars(template, database, table, columns, view_name, request, datasette)
|
||||||
--------------------------------------------------------------------------------------
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Extra template variables that should be made available in the rendered template context.
|
Extra template variables that should be made available in the rendered template context.
|
||||||
|
|
||||||
|
@ -184,7 +191,7 @@ Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-a
|
||||||
.. _plugin_hook_extra_css_urls:
|
.. _plugin_hook_extra_css_urls:
|
||||||
|
|
||||||
extra_css_urls(template, database, table, columns, view_name, request, datasette)
|
extra_css_urls(template, database, table, columns, view_name, request, datasette)
|
||||||
---------------------------------------------------------------------------------
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
This takes the same arguments as :ref:`extra_template_vars(...) <plugin_hook_extra_template_vars>`
|
This takes the same arguments as :ref:`extra_template_vars(...) <plugin_hook_extra_template_vars>`
|
||||||
|
|
||||||
|
@ -238,7 +245,7 @@ Examples: `datasette-cluster-map <https://datasette.io/plugins/datasette-cluster
|
||||||
.. _plugin_hook_extra_js_urls:
|
.. _plugin_hook_extra_js_urls:
|
||||||
|
|
||||||
extra_js_urls(template, database, table, columns, view_name, request, datasette)
|
extra_js_urls(template, database, table, columns, view_name, request, datasette)
|
||||||
--------------------------------------------------------------------------------
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
This takes the same arguments as :ref:`extra_template_vars(...) <plugin_hook_extra_template_vars>`
|
This takes the same arguments as :ref:`extra_template_vars(...) <plugin_hook_extra_template_vars>`
|
||||||
|
|
||||||
|
@ -288,7 +295,7 @@ Examples: `datasette-cluster-map <https://datasette.io/plugins/datasette-cluster
|
||||||
.. _plugin_hook_extra_body_script:
|
.. _plugin_hook_extra_body_script:
|
||||||
|
|
||||||
extra_body_script(template, database, table, columns, view_name, request, datasette)
|
extra_body_script(template, database, table, columns, view_name, request, datasette)
|
||||||
------------------------------------------------------------------------------------
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Extra JavaScript to be added to a ``<script>`` block at the end of the ``<body>`` element on the page.
|
Extra JavaScript to be added to a ``<script>`` block at the end of the ``<body>`` element on the page.
|
||||||
|
|
||||||
|
@ -487,7 +494,7 @@ This will register ``render_demo`` to be called when paths with the extension ``
|
||||||
|
|
||||||
``render_demo`` is a Python function. It can be a regular function or an ``async def render_demo()`` awaitable function, depending on if it needs to make any asynchronous calls.
|
``render_demo`` is a Python function. It can be a regular function or an ``async def render_demo()`` awaitable function, depending on if it needs to make any asynchronous calls.
|
||||||
|
|
||||||
``can_render_demo`` is a Python function (or ``async def`` function) which accepts the same arguments as ``render_demo`` but just returns ``True`` or ``False``. It lets Datasette know if the current SQL query can be represented by the plugin - and hence influnce if a link to this output format is displayed in the user interface. If you omit the ``"can_render"`` key from the dictionary every query will be treated as being supported by the plugin.
|
``can_render_demo`` is a Python function (or ``async def`` function) which accepts the same arguments as ``render_demo`` but just returns ``True`` or ``False``. It lets Datasette know if the current SQL query can be represented by the plugin - and hence influence if a link to this output format is displayed in the user interface. If you omit the ``"can_render"`` key from the dictionary every query will be treated as being supported by the plugin.
|
||||||
|
|
||||||
When a request is received, the ``"render"`` callback function is called with zero or more of the following arguments. Datasette will inspect your callback function and pass arguments that match its function signature.
|
When a request is received, the ``"render"`` callback function is called with zero or more of the following arguments. Datasette will inspect your callback function and pass arguments that match its function signature.
|
||||||
|
|
||||||
|
@ -1430,199 +1437,6 @@ This example logs an error to `Sentry <https://sentry.io/>`__ and then renders a
|
||||||
|
|
||||||
Example: `datasette-sentry <https://datasette.io/plugins/datasette-sentry>`_
|
Example: `datasette-sentry <https://datasette.io/plugins/datasette-sentry>`_
|
||||||
|
|
||||||
.. _plugin_hook_menu_links:
|
|
||||||
|
|
||||||
menu_links(datasette, actor, request)
|
|
||||||
-------------------------------------
|
|
||||||
|
|
||||||
``datasette`` - :ref:`internals_datasette`
|
|
||||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
|
||||||
|
|
||||||
``actor`` - dictionary or None
|
|
||||||
The currently authenticated :ref:`actor <authentication_actor>`.
|
|
||||||
|
|
||||||
``request`` - :ref:`internals_request` or None
|
|
||||||
The current HTTP request. This can be ``None`` if the request object is not available.
|
|
||||||
|
|
||||||
This hook allows additional items to be included in the menu displayed by Datasette's top right menu icon.
|
|
||||||
|
|
||||||
The hook should return a list of ``{"href": "...", "label": "..."}`` menu items. These will be added to the menu.
|
|
||||||
|
|
||||||
It can alternatively return an ``async def`` awaitable function which returns a list of menu items.
|
|
||||||
|
|
||||||
This example adds a new menu item but only if the signed in user is ``"root"``:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def menu_links(datasette, actor):
|
|
||||||
if actor and actor.get("id") == "root":
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"href": datasette.urls.path(
|
|
||||||
"/-/edit-schema"
|
|
||||||
),
|
|
||||||
"label": "Edit schema",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`setting_base_url` setting into account.
|
|
||||||
|
|
||||||
Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
|
|
||||||
|
|
||||||
.. _plugin_hook_table_actions:
|
|
||||||
|
|
||||||
table_actions(datasette, actor, database, table, request)
|
|
||||||
---------------------------------------------------------
|
|
||||||
|
|
||||||
``datasette`` - :ref:`internals_datasette`
|
|
||||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
|
||||||
|
|
||||||
``actor`` - dictionary or None
|
|
||||||
The currently authenticated :ref:`actor <authentication_actor>`.
|
|
||||||
|
|
||||||
``database`` - string
|
|
||||||
The name of the database.
|
|
||||||
|
|
||||||
``table`` - string
|
|
||||||
The name of the table.
|
|
||||||
|
|
||||||
``request`` - :ref:`internals_request` or None
|
|
||||||
The current HTTP request. This can be ``None`` if the request object is not available.
|
|
||||||
|
|
||||||
This hook allows table actions to be displayed in a menu accessed via an action icon at the top of the table page. It should return a list of ``{"href": "...", "label": "..."}`` menu items.
|
|
||||||
|
|
||||||
It can alternatively return an ``async def`` awaitable function which returns a list of menu items.
|
|
||||||
|
|
||||||
This example adds a new table action if the signed in user is ``"root"``:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def table_actions(datasette, actor, database, table):
|
|
||||||
if actor and actor.get("id") == "root":
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"href": datasette.urls.path(
|
|
||||||
"/-/edit-schema/{}/{}".format(
|
|
||||||
database, table
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"label": "Edit schema for this table",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
Example: `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
|
|
||||||
|
|
||||||
.. _plugin_hook_query_actions:
|
|
||||||
|
|
||||||
query_actions(datasette, actor, database, query_name, request, sql, params)
|
|
||||||
---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
``datasette`` - :ref:`internals_datasette`
|
|
||||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
|
||||||
|
|
||||||
``actor`` - dictionary or None
|
|
||||||
The currently authenticated :ref:`actor <authentication_actor>`.
|
|
||||||
|
|
||||||
``database`` - string
|
|
||||||
The name of the database.
|
|
||||||
|
|
||||||
``query_name`` - string or None
|
|
||||||
The name of the canned query, or ``None`` if this is an arbitrary SQL query.
|
|
||||||
|
|
||||||
``request`` - :ref:`internals_request`
|
|
||||||
The current HTTP request.
|
|
||||||
|
|
||||||
``sql`` - string
|
|
||||||
The SQL query being executed
|
|
||||||
|
|
||||||
``params`` - dictionary
|
|
||||||
The parameters passed to the SQL query, if any.
|
|
||||||
|
|
||||||
This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the canned query and arbitrary SQL query pages.
|
|
||||||
|
|
||||||
This example adds a new query action linking to a page for explaining a query:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
import urllib
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def query_actions(datasette, database, sql):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"href": datasette.urls.database(database)
|
|
||||||
+ "/-/explain?"
|
|
||||||
+ urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"sql": sql,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
"label": "Explain this query",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
.. _plugin_hook_database_actions:
|
|
||||||
|
|
||||||
database_actions(datasette, actor, database, request)
|
|
||||||
-----------------------------------------------------
|
|
||||||
|
|
||||||
``datasette`` - :ref:`internals_datasette`
|
|
||||||
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
|
||||||
|
|
||||||
``actor`` - dictionary or None
|
|
||||||
The currently authenticated :ref:`actor <authentication_actor>`.
|
|
||||||
|
|
||||||
``database`` - string
|
|
||||||
The name of the database.
|
|
||||||
|
|
||||||
``request`` - :ref:`internals_request`
|
|
||||||
The current HTTP request.
|
|
||||||
|
|
||||||
This hook is similar to :ref:`plugin_hook_table_actions` but populates an actions menu on the database page.
|
|
||||||
|
|
||||||
This example adds a new database action for creating a table, if the user has the ``edit-schema`` permission:
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from datasette import hookimpl
|
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
|
||||||
def database_actions(datasette, actor, database):
|
|
||||||
async def inner():
|
|
||||||
if not await datasette.permission_allowed(
|
|
||||||
actor,
|
|
||||||
"edit-schema",
|
|
||||||
resource=database,
|
|
||||||
default=False,
|
|
||||||
):
|
|
||||||
return []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"href": datasette.urls.path(
|
|
||||||
"/-/edit-schema/{}/-/create".format(
|
|
||||||
database
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"label": "Create a table",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
return inner
|
|
||||||
|
|
||||||
Example: `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_, `datasette-edit-schema <https://datasette.io/plugins/datasette-edit-schema>`_
|
|
||||||
|
|
||||||
.. _plugin_hook_skip_csrf:
|
.. _plugin_hook_skip_csrf:
|
||||||
|
|
||||||
skip_csrf(datasette, scope)
|
skip_csrf(datasette, scope)
|
||||||
|
@ -1693,6 +1507,316 @@ This hook is responsible for returning a dictionary corresponding to Datasette :
|
||||||
|
|
||||||
Example: `datasette-remote-metadata plugin <https://datasette.io/plugins/datasette-remote-metadata>`__
|
Example: `datasette-remote-metadata plugin <https://datasette.io/plugins/datasette-remote-metadata>`__
|
||||||
|
|
||||||
|
.. _plugin_hook_menu_links:
|
||||||
|
|
||||||
|
menu_links(datasette, actor, request)
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
``request`` - :ref:`internals_request` or None
|
||||||
|
The current HTTP request. This can be ``None`` if the request object is not available.
|
||||||
|
|
||||||
|
This hook allows additional items to be included in the menu displayed by Datasette's top right menu icon.
|
||||||
|
|
||||||
|
The hook should return a list of ``{"href": "...", "label": "..."}`` menu items. These will be added to the menu.
|
||||||
|
|
||||||
|
It can alternatively return an ``async def`` awaitable function which returns a list of menu items.
|
||||||
|
|
||||||
|
This example adds a new menu item but only if the signed in user is ``"root"``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def menu_links(datasette, actor):
|
||||||
|
if actor and actor.get("id") == "root":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.path(
|
||||||
|
"/-/edit-schema"
|
||||||
|
),
|
||||||
|
"label": "Edit schema",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
Using :ref:`internals_datasette_urls` here ensures that links in the menu will take the :ref:`setting_base_url` setting into account.
|
||||||
|
|
||||||
|
Examples: `datasette-search-all <https://datasette.io/plugins/datasette-search-all>`_, `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
|
||||||
|
|
||||||
|
.. _plugin_actions:
|
||||||
|
|
||||||
|
Action hooks
|
||||||
|
------------
|
||||||
|
|
||||||
|
Action hooks can be used to add items to the action menus that appear at the top of different pages within Datasette. Unlike :ref:`menu_links() <plugin_hook_menu_links>`, actions which are displayed on every page, actions should only be relevant to the page the user is currently viewing.
|
||||||
|
|
||||||
|
Each of these hooks should return return a list of ``{"href": "...", "label": "..."}`` menu items, with optional ``"description": "..."`` keys describing each action in more detail.
|
||||||
|
|
||||||
|
They can alternatively return an ``async def`` awaitable function which, when called, returns a list of those menu items.
|
||||||
|
|
||||||
|
.. _plugin_hook_table_actions:
|
||||||
|
|
||||||
|
table_actions(datasette, actor, database, table, request)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
``database`` - string
|
||||||
|
The name of the database.
|
||||||
|
|
||||||
|
``table`` - string
|
||||||
|
The name of the table.
|
||||||
|
|
||||||
|
``request`` - :ref:`internals_request` or None
|
||||||
|
The current HTTP request. This can be ``None`` if the request object is not available.
|
||||||
|
|
||||||
|
This example adds a new table action if the signed in user is ``"root"``:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def table_actions(datasette, actor, database, table):
|
||||||
|
if actor and actor.get("id") == "root":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.path(
|
||||||
|
"/-/edit-schema/{}/{}".format(
|
||||||
|
database, table
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"label": "Edit schema for this table",
|
||||||
|
"description": "Add, remove, rename or alter columns for this table.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Example: `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_
|
||||||
|
|
||||||
|
.. _plugin_hook_view_actions:
|
||||||
|
|
||||||
|
view_actions(datasette, actor, database, view, request)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
``database`` - string
|
||||||
|
The name of the database.
|
||||||
|
|
||||||
|
``view`` - string
|
||||||
|
The name of the SQL view.
|
||||||
|
|
||||||
|
``request`` - :ref:`internals_request` or None
|
||||||
|
The current HTTP request. This can be ``None`` if the request object is not available.
|
||||||
|
|
||||||
|
Like :ref:`plugin_hook_table_actions` but for SQL views.
|
||||||
|
|
||||||
|
.. _plugin_hook_query_actions:
|
||||||
|
|
||||||
|
query_actions(datasette, actor, database, query_name, request, sql, params)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
``database`` - string
|
||||||
|
The name of the database.
|
||||||
|
|
||||||
|
``query_name`` - string or None
|
||||||
|
The name of the canned query, or ``None`` if this is an arbitrary SQL query.
|
||||||
|
|
||||||
|
``request`` - :ref:`internals_request`
|
||||||
|
The current HTTP request.
|
||||||
|
|
||||||
|
``sql`` - string
|
||||||
|
The SQL query being executed
|
||||||
|
|
||||||
|
``params`` - dictionary
|
||||||
|
The parameters passed to the SQL query, if any.
|
||||||
|
|
||||||
|
Populates a "Query actions" menu on the canned query and arbitrary SQL query pages.
|
||||||
|
|
||||||
|
This example adds a new query action linking to a page for explaining a query:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
import urllib
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def query_actions(datasette, database, query_name, sql):
|
||||||
|
# Don't explain an explain
|
||||||
|
if sql.lower().startswith("explain"):
|
||||||
|
return
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.database(database)
|
||||||
|
+ "?"
|
||||||
|
+ urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"sql": "explain " + sql,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"label": "Explain this query",
|
||||||
|
"description": "Get a summary of how SQLite executes the query",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
Example: `datasette-create-view <https://datasette.io/plugins/datasette-create-view>`_
|
||||||
|
|
||||||
|
.. _plugin_hook_row_actions:
|
||||||
|
|
||||||
|
row_actions(datasette, actor, request, database, table, row)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
``request`` - :ref:`internals_request` or None
|
||||||
|
The current HTTP request.
|
||||||
|
|
||||||
|
``database`` - string
|
||||||
|
The name of the database.
|
||||||
|
|
||||||
|
``table`` - string
|
||||||
|
The name of the table.
|
||||||
|
|
||||||
|
``row`` - ``sqlite.Row``
|
||||||
|
The SQLite row object being displayed on the page.
|
||||||
|
|
||||||
|
Return links for the "Row actions" menu shown at the top of the row page.
|
||||||
|
|
||||||
|
This example displays the row in JSON plus some additional debug information if the user is signed in:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def row_actions(datasette, database, table, actor, row):
|
||||||
|
if actor:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.instance(),
|
||||||
|
"label": f"Row details for {actor['id']}",
|
||||||
|
"description": json.dumps(
|
||||||
|
dict(row), default=repr
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
Example: `datasette-enrichments <https://datasette.io/plugins/datasette-enrichments>`_
|
||||||
|
|
||||||
|
.. _plugin_hook_database_actions:
|
||||||
|
|
||||||
|
database_actions(datasette, actor, database, request)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
``database`` - string
|
||||||
|
The name of the database.
|
||||||
|
|
||||||
|
``request`` - :ref:`internals_request`
|
||||||
|
The current HTTP request.
|
||||||
|
|
||||||
|
Populates an actions menu on the database page.
|
||||||
|
|
||||||
|
This example adds a new database action for creating a table, if the user has the ``edit-schema`` permission:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def database_actions(datasette, actor, database):
|
||||||
|
async def inner():
|
||||||
|
if not await datasette.permission_allowed(
|
||||||
|
actor,
|
||||||
|
"edit-schema",
|
||||||
|
resource=database,
|
||||||
|
default=False,
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.path(
|
||||||
|
"/-/edit-schema/{}/-/create".format(
|
||||||
|
database
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"label": "Create a table",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
Example: `datasette-graphql <https://datasette.io/plugins/datasette-graphql>`_, `datasette-edit-schema <https://datasette.io/plugins/datasette-edit-schema>`_
|
||||||
|
|
||||||
|
.. _plugin_hook_homepage_actions:
|
||||||
|
|
||||||
|
homepage_actions(datasette, actor, request)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
``datasette`` - :ref:`internals_datasette`
|
||||||
|
You can use this to access plugin configuration options via ``datasette.plugin_config(your_plugin_name)``, or to execute SQL queries.
|
||||||
|
|
||||||
|
``actor`` - dictionary or None
|
||||||
|
The currently authenticated :ref:`actor <authentication_actor>`.
|
||||||
|
|
||||||
|
``request`` - :ref:`internals_request`
|
||||||
|
The current HTTP request.
|
||||||
|
|
||||||
|
Populates an actions menu on the top-level index homepage of the Datasette instance.
|
||||||
|
|
||||||
|
This example adds a link an imagined tool for editing the homepage, only for signed in users:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def homepage_actions(datasette, actor):
|
||||||
|
if actor:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.path(
|
||||||
|
"/-/customize-homepage"
|
||||||
|
),
|
||||||
|
"label": "Customize homepage",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
.. _plugin_hook_slots:
|
.. _plugin_hook_slots:
|
||||||
|
|
||||||
Template slots
|
Template slots
|
||||||
|
@ -1869,6 +1993,49 @@ This example plugin logs details of all events to standard error:
|
||||||
)
|
)
|
||||||
print(msg, file=sys.stderr, flush=True)
|
print(msg, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
The function can also return an async function which will be awaited. This is useful for writing to a database.
|
||||||
|
|
||||||
|
This example logs events to a `datasette_events` table in a database called `events`. It uses the `startup()` hook to create that table if it does not exist.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from datasette import hookimpl
|
||||||
|
import json
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def startup(datasette):
|
||||||
|
async def inner():
|
||||||
|
db = datasette.get_database("events")
|
||||||
|
await db.execute_write(
|
||||||
|
"""
|
||||||
|
create table if not exists datasette_events (
|
||||||
|
id integer primary key,
|
||||||
|
event_type text,
|
||||||
|
created text,
|
||||||
|
actor text,
|
||||||
|
properties text
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def track_event(datasette, event):
|
||||||
|
async def inner():
|
||||||
|
db = datasette.get_database("events")
|
||||||
|
properties = event.properties()
|
||||||
|
await db.execute_write(
|
||||||
|
"""
|
||||||
|
insert into datasette_events (event_type, created, actor, properties)
|
||||||
|
values (?, strftime('%Y-%m-%d %H:%M:%S', 'now'), ?, ?)
|
||||||
|
""",
|
||||||
|
(event.name, json.dumps(event.actor), json.dumps(properties)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return inner
|
||||||
|
|
||||||
Example: `datasette-events-db <https://datasette.io/plugins/datasette-events-db>`_
|
Example: `datasette-events-db <https://datasette.io/plugins/datasette-events-db>`_
|
||||||
|
|
||||||
.. _plugin_hook_register_events:
|
.. _plugin_hook_register_events:
|
||||||
|
|
|
@ -42,6 +42,7 @@ EXPECTED_PLUGINS = [
|
||||||
"extra_js_urls",
|
"extra_js_urls",
|
||||||
"extra_template_vars",
|
"extra_template_vars",
|
||||||
"forbidden",
|
"forbidden",
|
||||||
|
"homepage_actions",
|
||||||
"menu_links",
|
"menu_links",
|
||||||
"permission_allowed",
|
"permission_allowed",
|
||||||
"prepare_connection",
|
"prepare_connection",
|
||||||
|
@ -52,9 +53,11 @@ EXPECTED_PLUGINS = [
|
||||||
"register_permissions",
|
"register_permissions",
|
||||||
"register_routes",
|
"register_routes",
|
||||||
"render_cell",
|
"render_cell",
|
||||||
|
"row_actions",
|
||||||
"skip_csrf",
|
"skip_csrf",
|
||||||
"startup",
|
"startup",
|
||||||
"table_actions",
|
"table_actions",
|
||||||
|
"view_actions",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -391,23 +391,50 @@ def table_actions(datasette, database, table, actor):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def view_actions(datasette, database, view, actor):
|
||||||
|
if actor:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.instance(),
|
||||||
|
"label": f"Database: {database}",
|
||||||
|
},
|
||||||
|
{"href": datasette.urls.instance(), "label": f"View: {view}"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def query_actions(datasette, database, query_name, sql):
|
def query_actions(datasette, database, query_name, sql):
|
||||||
args = {
|
# Don't explain an explain
|
||||||
"sql": sql,
|
if sql.lower().startswith("explain"):
|
||||||
}
|
return
|
||||||
if query_name:
|
|
||||||
args["query_name"] = query_name
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"href": datasette.urls.database(database)
|
"href": datasette.urls.database(database)
|
||||||
+ "/-/explain?"
|
+ "?"
|
||||||
+ urllib.parse.urlencode(args),
|
+ urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"sql": "explain " + sql,
|
||||||
|
}
|
||||||
|
),
|
||||||
"label": "Explain this query",
|
"label": "Explain this query",
|
||||||
|
"description": "Runs a SQLite explain",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def row_actions(datasette, database, table, actor, row):
|
||||||
|
if actor:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.instance(),
|
||||||
|
"label": f"Row details for {actor['id']}",
|
||||||
|
"description": json.dumps(dict(row), default=repr),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def database_actions(datasette, database, actor, request):
|
def database_actions(datasette, database, actor, request):
|
||||||
if actor:
|
if actor:
|
||||||
|
@ -422,6 +449,18 @@ def database_actions(datasette, database, actor, request):
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def homepage_actions(datasette, actor, request):
|
||||||
|
if actor:
|
||||||
|
label = f"Custom homepage for: {actor['id']}"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"href": datasette.urls.path("/-/custom-homepage"),
|
||||||
|
"label": label,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@hookimpl
|
@hookimpl
|
||||||
def skip_csrf(scope):
|
def skip_csrf(scope):
|
||||||
return scope["path"] == "/skip-csrf"
|
return scope["path"] == "/skip-csrf"
|
||||||
|
|
|
@ -1018,6 +1018,21 @@ async def test_hidden_sqlite_stat1_table():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hide_tables_starting_with_underscore():
|
||||||
|
ds = Datasette()
|
||||||
|
db = ds.add_memory_database("test_hide_tables_starting_with_underscore")
|
||||||
|
await db.execute_write("create table normal (id integer primary key, name text)")
|
||||||
|
await db.execute_write("create table _hidden (id integer primary key, name text)")
|
||||||
|
data = (
|
||||||
|
await ds.client.get(
|
||||||
|
"/test_hide_tables_starting_with_underscore.json?_show_hidden=1"
|
||||||
|
)
|
||||||
|
).json()
|
||||||
|
tables = [(t["name"], t["hidden"]) for t in data["tables"]]
|
||||||
|
assert tables == [("normal", False), ("_hidden", True)]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("db_name", ("foo", r"fo%o", "f~/c.d"))
|
@pytest.mark.parametrize("db_name", ("foo", r"fo%o", "f~/c.d"))
|
||||||
async def test_tilde_encoded_database_names(db_name):
|
async def test_tilde_encoded_database_names(db_name):
|
||||||
|
|
|
@ -110,7 +110,7 @@ async def test_logout_button_in_navigation(ds_client, path):
|
||||||
anon_response = await ds_client.get(path)
|
anon_response = await ds_client.get(path)
|
||||||
for fragment in (
|
for fragment in (
|
||||||
"<strong>test</strong>",
|
"<strong>test</strong>",
|
||||||
'<form action="/-/logout" method="post">',
|
'<form class="nav-menu-logout" action="/-/logout" method="post">',
|
||||||
):
|
):
|
||||||
assert fragment in response.text
|
assert fragment in response.text
|
||||||
assert fragment not in anon_response.text
|
assert fragment not in anon_response.text
|
||||||
|
@ -121,7 +121,10 @@ async def test_logout_button_in_navigation(ds_client, path):
|
||||||
async def test_no_logout_button_in_navigation_if_no_ds_actor_cookie(ds_client, path):
|
async def test_no_logout_button_in_navigation_if_no_ds_actor_cookie(ds_client, path):
|
||||||
response = await ds_client.get(path + "?_bot=1")
|
response = await ds_client.get(path + "?_bot=1")
|
||||||
assert "<strong>bot</strong>" in response.text
|
assert "<strong>bot</strong>" in response.text
|
||||||
assert '<form action="/-/logout" method="post">' not in response.text
|
assert (
|
||||||
|
'<form class="nav-menu-logout" action="/-/logout" method="post">'
|
||||||
|
not in response.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
|
|
|
@ -78,6 +78,10 @@ async def test_static(ds_client):
|
||||||
response = await ds_client.get("/-/static/app.css")
|
response = await ds_client.get("/-/static/app.css")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "text/css" == response.headers["content-type"]
|
assert "text/css" == response.headers["content-type"]
|
||||||
|
assert "etag" in response.headers
|
||||||
|
etag = response.headers.get("etag")
|
||||||
|
response = await ds_client.get("/-/static/app.css", headers={"if-none-match": etag})
|
||||||
|
assert response.status_code == 304
|
||||||
|
|
||||||
|
|
||||||
def test_static_mounts():
|
def test_static_mounts():
|
||||||
|
|
|
@ -923,68 +923,128 @@ async def test_hook_menu_links(ds_client):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("table_or_view", ["facetable", "simple_view"])
|
async def test_hook_table_actions(ds_client):
|
||||||
async def test_hook_table_actions(ds_client, table_or_view):
|
response = await ds_client.get("/fixtures/facetable")
|
||||||
def get_table_actions_links(html):
|
assert get_actions_links(response.text) == []
|
||||||
soup = Soup(html, "html.parser")
|
response_2 = await ds_client.get("/fixtures/facetable?_bot=1&_hello=BOB")
|
||||||
details = soup.find("details", {"class": "actions-menu-links"})
|
assert ">Table actions<" in response_2.text
|
||||||
if details is None:
|
|
||||||
return []
|
|
||||||
return [{"label": a.text, "href": a["href"]} for a in details.select("a")]
|
|
||||||
|
|
||||||
response = await ds_client.get(f"/fixtures/{table_or_view}")
|
|
||||||
assert get_table_actions_links(response.text) == []
|
|
||||||
|
|
||||||
response_2 = await ds_client.get(f"/fixtures/{table_or_view}?_bot=1&_hello=BOB")
|
|
||||||
assert sorted(
|
assert sorted(
|
||||||
get_table_actions_links(response_2.text), key=lambda link: link["label"]
|
get_actions_links(response_2.text), key=lambda link: link["label"]
|
||||||
) == [
|
) == [
|
||||||
{"label": "Database: fixtures", "href": "/"},
|
{"label": "Database: fixtures", "href": "/", "description": None},
|
||||||
{"label": "From async BOB", "href": "/"},
|
{"label": "From async BOB", "href": "/", "description": None},
|
||||||
{"label": f"Table: {table_or_view}", "href": "/"},
|
{"label": "Table: facetable", "href": "/", "description": None},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_view_actions(ds_client):
|
||||||
|
response = await ds_client.get("/fixtures/simple_view")
|
||||||
|
assert get_actions_links(response.text) == []
|
||||||
|
response_2 = await ds_client.get(
|
||||||
|
"/fixtures/simple_view",
|
||||||
|
cookies={"ds_actor": ds_client.actor_cookie({"id": "bob"})},
|
||||||
|
)
|
||||||
|
assert ">View actions<" in response_2.text
|
||||||
|
assert sorted(
|
||||||
|
get_actions_links(response_2.text), key=lambda link: link["label"]
|
||||||
|
) == [
|
||||||
|
{"label": "Database: fixtures", "href": "/", "description": None},
|
||||||
|
{"label": "View: simple_view", "href": "/", "description": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_actions_links(html):
|
||||||
|
soup = Soup(html, "html.parser")
|
||||||
|
details = soup.find("details", {"class": "actions-menu-links"})
|
||||||
|
if details is None:
|
||||||
|
return []
|
||||||
|
links = []
|
||||||
|
for a_el in details.select("a"):
|
||||||
|
description = None
|
||||||
|
if a_el.find("p") is not None:
|
||||||
|
description = a_el.find("p").text.strip()
|
||||||
|
a_el.find("p").extract()
|
||||||
|
label = a_el.text.strip()
|
||||||
|
href = a_el["href"]
|
||||||
|
links.append({"label": label, "href": href, "description": description})
|
||||||
|
return links
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"path,expected_url",
|
"path,expected_url",
|
||||||
(
|
(
|
||||||
("/fixtures?sql=select+1", "/fixtures/-/explain?sql=select+1"),
|
("/fixtures?sql=select+1", "/fixtures?sql=explain+select+1"),
|
||||||
(
|
(
|
||||||
"/fixtures/pragma_cache_size",
|
"/fixtures/pragma_cache_size",
|
||||||
"/fixtures/-/explain?sql=PRAGMA+cache_size%3B&query_name=pragma_cache_size",
|
"/fixtures?sql=explain+PRAGMA+cache_size%3B",
|
||||||
),
|
),
|
||||||
|
# Don't attempt to explain an explain
|
||||||
|
("/fixtures?sql=explain+select+1", None),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
async def test_hook_query_actions(ds_client, path, expected_url):
|
async def test_hook_query_actions(ds_client, path, expected_url):
|
||||||
def get_table_actions_links(html):
|
|
||||||
soup = Soup(html, "html.parser")
|
|
||||||
details = soup.find("details", {"class": "actions-menu-links"})
|
|
||||||
if details is None:
|
|
||||||
return []
|
|
||||||
return [{"label": a.text, "href": a["href"]} for a in details.select("a")]
|
|
||||||
|
|
||||||
response = await ds_client.get(path)
|
response = await ds_client.get(path)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
links = get_table_actions_links(response.text)
|
links = get_actions_links(response.text)
|
||||||
assert links == [{"label": "Explain this query", "href": expected_url}]
|
if expected_url is None:
|
||||||
|
assert links == []
|
||||||
|
else:
|
||||||
|
assert links == [
|
||||||
|
{
|
||||||
|
"label": "Explain this query",
|
||||||
|
"href": expected_url,
|
||||||
|
"description": "Runs a SQLite explain",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_row_actions(ds_client):
|
||||||
|
response = await ds_client.get("/fixtures/facet_cities/1")
|
||||||
|
assert get_actions_links(response.text) == []
|
||||||
|
|
||||||
|
response_2 = await ds_client.get(
|
||||||
|
"/fixtures/facet_cities/1",
|
||||||
|
cookies={"ds_actor": ds_client.actor_cookie({"id": "sam"})},
|
||||||
|
)
|
||||||
|
assert get_actions_links(response_2.text) == [
|
||||||
|
{
|
||||||
|
"label": "Row details for sam",
|
||||||
|
"href": "/",
|
||||||
|
"description": '{"id": 1, "name": "San Francisco"}',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_hook_database_actions(ds_client):
|
async def test_hook_database_actions(ds_client):
|
||||||
def get_table_actions_links(html):
|
|
||||||
soup = Soup(html, "html.parser")
|
|
||||||
details = soup.find("details", {"class": "actions-menu-links"})
|
|
||||||
if details is None:
|
|
||||||
return []
|
|
||||||
return [{"label": a.text, "href": a["href"]} for a in details.select("a")]
|
|
||||||
|
|
||||||
response = await ds_client.get("/fixtures")
|
response = await ds_client.get("/fixtures")
|
||||||
assert get_table_actions_links(response.text) == []
|
assert get_actions_links(response.text) == []
|
||||||
|
|
||||||
response_2 = await ds_client.get("/fixtures?_bot=1&_hello=BOB")
|
response_2 = await ds_client.get("/fixtures?_bot=1&_hello=BOB")
|
||||||
assert get_table_actions_links(response_2.text) == [
|
assert get_actions_links(response_2.text) == [
|
||||||
{"label": "Database: fixtures - BOB", "href": "/"},
|
{"label": "Database: fixtures - BOB", "href": "/", "description": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hook_homepage_actions(ds_client):
|
||||||
|
response = await ds_client.get("/")
|
||||||
|
# No button for anonymous users
|
||||||
|
assert "<span>Homepage actions</span>" not in response.text
|
||||||
|
# Signed in user gets an action
|
||||||
|
response2 = await ds_client.get(
|
||||||
|
"/", cookies={"ds_actor": ds_client.actor_cookie({"id": "troy"})}
|
||||||
|
)
|
||||||
|
assert "<span>Homepage actions</span>" in response2.text
|
||||||
|
assert get_actions_links(response2.text) == [
|
||||||
|
{
|
||||||
|
"label": "Custom homepage for: troy",
|
||||||
|
"href": "/-/custom-homepage",
|
||||||
|
"description": None,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -706,3 +706,15 @@ def test_truncate_url(url, length, expected):
|
||||||
def test_pairs_to_nested_config(pairs, expected):
|
def test_pairs_to_nested_config(pairs, expected):
|
||||||
actual = utils.pairs_to_nested_config(pairs)
|
actual = utils.pairs_to_nested_config(pairs)
|
||||||
assert actual == expected
|
assert actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_etag(tmp_path):
|
||||||
|
path = tmp_path / "test.txt"
|
||||||
|
path.write_text("hello")
|
||||||
|
etag = '"5d41402abc4b2a76b9719d911017c592"'
|
||||||
|
assert etag == await utils.calculate_etag(path)
|
||||||
|
assert utils._etag_cache[path] == etag
|
||||||
|
utils._etag_cache[path] = "hash"
|
||||||
|
assert "hash" == await utils.calculate_etag(path)
|
||||||
|
utils._etag_cache.clear()
|
||||||
|
|
Ładowanie…
Reference in New Issue