Start making a structure for general UI specification
This commit is contained in:
parent
68c057f890
commit
a11f9b7f62
1 changed files with 152 additions and 2 deletions
|
|
@ -1,11 +1,127 @@
|
||||||
"""Functionality for communicating the required user interface for a thing."""
|
"""Functionality for communicating the required user interface for a thing."""
|
||||||
|
|
||||||
from typing import Any, Optional
|
from html import escape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from typing import Annotated, Any, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, BeforeValidator
|
||||||
|
|
||||||
import labthings_fastapi as lt
|
import labthings_fastapi as lt
|
||||||
|
|
||||||
|
ALLOWED_TAGS = {
|
||||||
|
"p",
|
||||||
|
"i",
|
||||||
|
"b",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"ul",
|
||||||
|
"li",
|
||||||
|
"a",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SafeHTMLParser(HTMLParser):
|
||||||
|
"""An HTMLParser that only allows a very limited subset of HTML."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialise the parser."""
|
||||||
|
super().__init__()
|
||||||
|
self.output = ""
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: dict[str, str]) -> None:
|
||||||
|
"""Append a start tag found in the HTML if it is in the allowed list.
|
||||||
|
|
||||||
|
This is called by the base class when a start tag is found. We only append
|
||||||
|
the tag if it is our allowed list. We re-write the tag so no extra attributes
|
||||||
|
can be added.
|
||||||
|
|
||||||
|
No tags can have attributes except for ``a`` tags only allows ``href``. We then
|
||||||
|
append ``target="_blank" rel="noopener noreferrer"`` to ensure the link opens
|
||||||
|
in a new window.
|
||||||
|
|
||||||
|
:param tag: the tag name
|
||||||
|
:param attrs: the attributes for the tag.
|
||||||
|
"""
|
||||||
|
if tag not in ALLOWED_TAGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == "a":
|
||||||
|
href = None
|
||||||
|
for attr, value in attrs:
|
||||||
|
if attr == "href":
|
||||||
|
href = value.strip()
|
||||||
|
|
||||||
|
if href:
|
||||||
|
safe_href = escape(href, quote=True)
|
||||||
|
# Always in a new tab
|
||||||
|
self.output.append(
|
||||||
|
f'<a href="{safe_href}" target="_blank" rel="noopener noreferrer">'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.output += "<a>"
|
||||||
|
else:
|
||||||
|
self.output += f"<{tag}>"
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
"""Append the end tag only if it is in the allowed list."""
|
||||||
|
if tag in ALLOWED_TAGS:
|
||||||
|
self.output += f"</{tag}>"
|
||||||
|
|
||||||
|
def handle_startendtag(self, tag: str, _attrs: dict[str, str]) -> None:
|
||||||
|
"""Append a self-closing tag if it is a new line."""
|
||||||
|
if tag == "br":
|
||||||
|
self.output += "<br/>"
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
"""Append any data inside tags after escaping it."""
|
||||||
|
self.output += escape(data)
|
||||||
|
|
||||||
|
def handle_entityref(self, name: str) -> None:
|
||||||
|
"""Append any named character."""
|
||||||
|
self.output += f"&{name};"
|
||||||
|
|
||||||
|
def handle_charref(self, name: str) -> None:
|
||||||
|
"""Append any character references."""
|
||||||
|
self.output += f"&#{name};"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html(html: str) -> str:
|
||||||
|
"""Santitise HTML to only have a small list of allowed tags.
|
||||||
|
|
||||||
|
Tags allowed without attrs: ``<p>, <i>, <b>, <h1>, <h2>, <h3>, <h4>, <h5>, <h6>,``
|
||||||
|
``<ul>, <li>``
|
||||||
|
|
||||||
|
Tags allowed with attrs: ``<a>`` is allowed with ``href`` only. This automatically
|
||||||
|
appends ``target="_blank" rel="noopener noreferrer"`` so the link opens externally.
|
||||||
|
|
||||||
|
Self closing tags allowed ``<br>``.
|
||||||
|
|
||||||
|
:param html: The input HTML.
|
||||||
|
|
||||||
|
:return: The sanitised HTML.
|
||||||
|
"""
|
||||||
|
parser = SafeHTMLParser()
|
||||||
|
parser.feed(html)
|
||||||
|
parser.close()
|
||||||
|
return parser.output
|
||||||
|
|
||||||
|
|
||||||
|
HtmlFragment = Annotated[str, BeforeValidator(sanitize_html)]
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLBlock(BaseModel):
|
||||||
|
"""The data required for creating a block of HTML for the UI.
|
||||||
|
|
||||||
|
The HTML is limited to a small number of tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["html_block"] = "html_block"
|
||||||
|
html: HtmlFragment
|
||||||
|
|
||||||
|
|
||||||
class ActionButton(BaseModel):
|
class ActionButton(BaseModel):
|
||||||
"""The data required for creating an actionButton in Vue.
|
"""The data required for creating an actionButton in Vue.
|
||||||
|
|
@ -13,6 +129,8 @@ class ActionButton(BaseModel):
|
||||||
Currently this cannot be used to submitData, or to submitOnEvent.
|
Currently this cannot be used to submitData, or to submitOnEvent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["action_button"] = "action_button"
|
||||||
|
|
||||||
thing: str
|
thing: str
|
||||||
"""The Thing "path" for the Thing instance."""
|
"""The Thing "path" for the Thing instance."""
|
||||||
|
|
||||||
|
|
@ -72,6 +190,8 @@ class PropertyControl(BaseModel):
|
||||||
Currently this cannot be used to submitData, or to submitOnEvent.
|
Currently this cannot be used to submitData, or to submitOnEvent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["property_control"] = "property_control"
|
||||||
|
|
||||||
thing: str
|
thing: str
|
||||||
"""The Thing "path" for the Thing instance."""
|
"""The Thing "path" for the Thing instance."""
|
||||||
|
|
||||||
|
|
@ -121,3 +241,33 @@ def property_control_for(
|
||||||
if "label" not in kwargs:
|
if "label" not in kwargs:
|
||||||
kwargs["label"] = property_name
|
kwargs["label"] = property_name
|
||||||
return PropertyControl(thing=thing.name, property_name=property_name, **kwargs)
|
return PropertyControl(thing=thing.name, property_name=property_name, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Accordion(BaseModel):
|
||||||
|
"""The data required for creating an accordion in the UI.
|
||||||
|
|
||||||
|
The HTML is limited to a small number of tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["accordion"] = "accordion"
|
||||||
|
title: str
|
||||||
|
children: list["UIElementModels"]
|
||||||
|
|
||||||
|
|
||||||
|
class Container(BaseModel):
|
||||||
|
"""The data required for creating ``<div>`` in the UI.
|
||||||
|
|
||||||
|
The HTML is limited to a small number of tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["container"] = "container"
|
||||||
|
css_class: str
|
||||||
|
children: list["UIElementModels"]
|
||||||
|
|
||||||
|
|
||||||
|
UIElementModels = HTMLBlock | PropertyControl | ActionButton | Accordion | Container
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve forward references so Pydantic can build a sensible recursive schema
|
||||||
|
Accordion.model_rebuild()
|
||||||
|
Container.model_rebuild()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue