Start making a structure for general UI specification

This commit is contained in:
Julian Stirling 2026-03-08 00:16:51 +00:00
parent 68c057f890
commit a11f9b7f62

View file

@ -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()