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."""
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
"""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.
|
||||
"""
|
||||
|
||||
element_type: Literal["action_button"] = "action_button"
|
||||
|
||||
thing: str
|
||||
"""The Thing "path" for the Thing instance."""
|
||||
|
||||
|
|
@ -72,6 +190,8 @@ class PropertyControl(BaseModel):
|
|||
Currently this cannot be used to submitData, or to submitOnEvent.
|
||||
"""
|
||||
|
||||
element_type: Literal["property_control"] = "property_control"
|
||||
|
||||
thing: str
|
||||
"""The Thing "path" for the Thing instance."""
|
||||
|
||||
|
|
@ -121,3 +241,33 @@ def property_control_for(
|
|||
if "label" not in kwargs:
|
||||
kwargs["label"] = property_name
|
||||
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