This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/main by this push:
new ebf892b Add a user interface development guide to the documentation
ebf892b is described below
commit ebf892b3e972d8852d9c8e0db4a6527d0bc920bb
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Oct 12 13:09:59 2025 +0100
Add a user interface development guide to the documentation
---
atr/docs/build-processes.html | 6 +-
atr/docs/build-processes.md | 6 +-
atr/docs/code-conventions.html | 6 +-
atr/docs/code-conventions.md | 6 +-
atr/docs/developer-guide.html | 7 +-
atr/docs/developer-guide.md | 7 +-
atr/docs/how-to-contribute.html | 4 +-
atr/docs/how-to-contribute.md | 4 +-
atr/docs/index.html | 7 +-
atr/docs/index.md | 7 +-
atr/docs/overview-of-the-code.html | 1 +
atr/docs/overview-of-the-code.md | 2 +
atr/docs/storage-interface.html | 2 +-
atr/docs/storage-interface.md | 2 +-
atr/docs/user-interface.html | 112 ++++++++++++++++++++++++++
atr/docs/user-interface.md | 157 +++++++++++++++++++++++++++++++++++++
atr/routes/keys.py | 2 +-
17 files changed, 307 insertions(+), 31 deletions(-)
diff --git a/atr/docs/build-processes.html b/atr/docs/build-processes.html
index 0962705..f7a270c 100644
--- a/atr/docs/build-processes.html
+++ b/atr/docs/build-processes.html
@@ -1,7 +1,7 @@
-<h1 id="build-processes">3.5. Build processes</h1>
+<h1 id="build-processes">3.6. Build processes</h1>
<p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer
guide</a></p>
-<p><strong>Prev</strong>: <code>3.4.</code> <a
href="storage-interface">Storage interface</a></p>
-<p><strong>Next</strong>: <code>3.6.</code> <a href="code-conventions">Code
conventions</a></p>
+<p><strong>Prev</strong>: <code>3.5.</code> <a href="user-interface">User
interface</a></p>
+<p><strong>Next</strong>: <code>3.7.</code> <a href="code-conventions">Code
conventions</a></p>
<p><strong>Sections</strong>:</p>
<ul>
<li><a href="#documentation-build-script">Documentation build script</a></li>
diff --git a/atr/docs/build-processes.md b/atr/docs/build-processes.md
index ed51416..c5bb313 100644
--- a/atr/docs/build-processes.md
+++ b/atr/docs/build-processes.md
@@ -1,10 +1,10 @@
-# 3.5. Build processes
+# 3.6. Build processes
**Up**: `3.` [Developer guide](developer-guide)
-**Prev**: `3.4.` [Storage interface](storage-interface)
+**Prev**: `3.5.` [User interface](user-interface)
-**Next**: `3.6.` [Code conventions](code-conventions)
+**Next**: `3.7.` [Code conventions](code-conventions)
**Sections**:
diff --git a/atr/docs/code-conventions.html b/atr/docs/code-conventions.html
index f959f8a..0a5b59b 100644
--- a/atr/docs/code-conventions.html
+++ b/atr/docs/code-conventions.html
@@ -1,7 +1,7 @@
-<h1 id="code-conventions">3.6. Code conventions</h1>
+<h1 id="code-conventions">3.7. Code conventions</h1>
<p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer
guide</a></p>
-<p><strong>Prev</strong>: <code>3.5.</code> <a href="build-processes">Build
processes</a></p>
-<p><strong>Next</strong>: <code>3.7.</code> <a href="how-to-contribute">How to
contribute</a></p>
+<p><strong>Prev</strong>: <code>3.6.</code> <a href="build-processes">Build
processes</a></p>
+<p><strong>Next</strong>: <code>3.8.</code> <a href="how-to-contribute">How to
contribute</a></p>
<p><strong>Sections</strong>:</p>
<ul>
<li><a href="#python-code">Python code</a></li>
diff --git a/atr/docs/code-conventions.md b/atr/docs/code-conventions.md
index 03fe859..8ec5b99 100644
--- a/atr/docs/code-conventions.md
+++ b/atr/docs/code-conventions.md
@@ -1,10 +1,10 @@
-# 3.6. Code conventions
+# 3.7. Code conventions
**Up**: `3.` [Developer guide](developer-guide)
-**Prev**: `3.5.` [Build processes](build-processes)
+**Prev**: `3.6.` [Build processes](build-processes)
-**Next**: `3.7.` [How to contribute](how-to-contribute)
+**Next**: `3.8.` [How to contribute](how-to-contribute)
**Sections**:
diff --git a/atr/docs/developer-guide.html b/atr/docs/developer-guide.html
index bb0fc47..bab6ec6 100644
--- a/atr/docs/developer-guide.html
+++ b/atr/docs/developer-guide.html
@@ -8,9 +8,10 @@
<li><code>3.2.</code> <a href="overview-of-the-code">Overview of the
code</a></li>
<li><code>3.3.</code> <a href="database">Database</a></li>
<li><code>3.4.</code> <a href="storage-interface">Storage interface</a></li>
-<li><code>3.5.</code> <a href="build-processes">Build processes</a></li>
-<li><code>3.6.</code> <a href="code-conventions">Code conventions</a></li>
-<li><code>3.7.</code> <a href="how-to-contribute">How to contribute</a></li>
+<li><code>3.5.</code> <a href="user-interface">User interface</a></li>
+<li><code>3.6.</code> <a href="build-processes">Build processes</a></li>
+<li><code>3.7.</code> <a href="code-conventions">Code conventions</a></li>
+<li><code>3.8.</code> <a href="how-to-contribute">How to contribute</a></li>
</ul>
<p><strong>Sections</strong>:</p>
<ul>
diff --git a/atr/docs/developer-guide.md b/atr/docs/developer-guide.md
index 1880239..5d5e798 100644
--- a/atr/docs/developer-guide.md
+++ b/atr/docs/developer-guide.md
@@ -12,9 +12,10 @@
* `3.2.` [Overview of the code](overview-of-the-code)
* `3.3.` [Database](database)
* `3.4.` [Storage interface](storage-interface)
-* `3.5.` [Build processes](build-processes)
-* `3.6.` [Code conventions](code-conventions)
-* `3.7.` [How to contribute](how-to-contribute)
+* `3.5.` [User interface](user-interface)
+* `3.6.` [Build processes](build-processes)
+* `3.7.` [Code conventions](code-conventions)
+* `3.8.` [How to contribute](how-to-contribute)
**Sections**:
diff --git a/atr/docs/how-to-contribute.html b/atr/docs/how-to-contribute.html
index b80c5f1..8beb49b 100644
--- a/atr/docs/how-to-contribute.html
+++ b/atr/docs/how-to-contribute.html
@@ -1,6 +1,6 @@
-<h1 id="how-to-contribute">3.7. How to contribute</h1>
+<h1 id="how-to-contribute">3.8. How to contribute</h1>
<p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer
guide</a></p>
-<p><strong>Prev</strong>: <code>3.6.</code> <a href="code-conventions">Code
conventions</a></p>
+<p><strong>Prev</strong>: <code>3.7.</code> <a href="code-conventions">Code
conventions</a></p>
<p><strong>Next</strong>: (none)</p>
<p><strong>Sections</strong>:</p>
<ul>
diff --git a/atr/docs/how-to-contribute.md b/atr/docs/how-to-contribute.md
index d0f0762..b49ff15 100644
--- a/atr/docs/how-to-contribute.md
+++ b/atr/docs/how-to-contribute.md
@@ -1,8 +1,8 @@
-# 3.7. How to contribute
+# 3.8. How to contribute
**Up**: `3.` [Developer guide](developer-guide)
-**Prev**: `3.6.` [Code conventions](code-conventions)
+**Prev**: `3.7.` [Code conventions](code-conventions)
**Next**: (none)
diff --git a/atr/docs/index.html b/atr/docs/index.html
index f312ed4..702cfe5 100644
--- a/atr/docs/index.html
+++ b/atr/docs/index.html
@@ -11,9 +11,10 @@
<li><code>3.2.</code> <a href="overview-of-the-code">Overview of the
code</a></li>
<li><code>3.3.</code> <a href="database">Database</a></li>
<li><code>3.4.</code> <a href="storage-interface">Storage interface</a></li>
-<li><code>3.5.</code> <a href="build-processes">Build processes</a></li>
-<li><code>3.6.</code> <a href="code-conventions">Code conventions</a></li>
-<li><code>3.7.</code> <a href="how-to-contribute">How to contribute</a></li>
+<li><code>3.5.</code> <a href="user-interface">User interface</a></li>
+<li><code>3.6.</code> <a href="build-processes">Build processes</a></li>
+<li><code>3.7.</code> <a href="code-conventions">Code conventions</a></li>
+<li><code>3.8.</code> <a href="how-to-contribute">How to contribute</a></li>
</ul>
</li>
</ul>
diff --git a/atr/docs/index.md b/atr/docs/index.md
index 9b801b8..28bb016 100644
--- a/atr/docs/index.md
+++ b/atr/docs/index.md
@@ -13,6 +13,7 @@ NOTE: This documentation is a work in progress.
* `3.2.` [Overview of the code](overview-of-the-code)
* `3.3.` [Database](database)
* `3.4.` [Storage interface](storage-interface)
- * `3.5.` [Build processes](build-processes)
- * `3.6.` [Code conventions](code-conventions)
- * `3.7.` [How to contribute](how-to-contribute)
+ * `3.5.` [User interface](user-interface)
+ * `3.6.` [Build processes](build-processes)
+ * `3.7.` [Code conventions](code-conventions)
+ * `3.8.` [How to contribute](how-to-contribute)
diff --git a/atr/docs/overview-of-the-code.html
b/atr/docs/overview-of-the-code.html
index 4da090b..86b55db 100644
--- a/atr/docs/overview-of-the-code.html
+++ b/atr/docs/overview-of-the-code.html
@@ -31,6 +31,7 @@
<p>Template rendering can be slow if templates are loaded from disk on every
request. To address this, we use <a
href="/ref/atr/preload.py"><code>preload</code></a> to load all templates into
memory before the server starts serving requests. The <a
href="/ref/atr/preload.py:setup_template_preloading"><code>preload.setup_template_preloading</code></a>
function registers a startup hook that finds and caches every template
file.</p>
<p>The ATR user interface includes many HTML forms. We use <a
href="https://wtforms.readthedocs.io/">WTForms</a> for form handling, accessed
through the ATR <a href="/ref/atr/forms.py"><code>forms</code></a> module. The
<a href="/ref/atr/forms.py:Typed"><code>forms.Typed</code></a> base class
extends the standard <code>QuartForm</code> class in <a
href="https://quart-wtf.readthedocs.io/">Quart-WTF</a>. Each form field is
created using helper functions such as <a href="/ref/atr/forms.py:s [...]
<p>In addition to templates, we sometimes need to generate HTML
programmatically in Python. For this we use <a
href="https://htpy.dev/">htpy</a>, another third party library, for building
HTML using Python syntax. The ATR <a
href="/ref/atr/htm.py"><code>htm</code></a> module extends htpy with a <a
href="/ref/atr/htm.py:Block"><code>Block</code></a> class that makes it easier
to build complex HTML structures incrementally. Using htpy means that we get
type checking for our HTML generation [...]
+<p>Refer to the <a href="user-interface">full user interface development
guide</a> for more information about this topic.</p>
<h2 id="scheduling-and-tasks">Scheduling and tasks</h2>
<p>Many operations in ATR are too slow to run during an HTTP request, so we
run them asynchronously in background worker processes. The task scheduling
system in ATR is built from three components: a task queue stored in the SQLite
database, a worker manager that spawns and monitors worker processes, and the
worker processes themselves that claim and execute tasks.</p>
<p>The task queue is stored in a table defined by the <a
href="/ref/atr/models/sql.py:Task"><code>Task</code></a> model in <a
href="/ref/atr/models/sql.py"><code>models.sql</code></a>. Each task has a
status, a type, arguments encoded as JSON, and metadata such as when it was
added and which user created it. When route handlers need to perform slow
operations, they create a new <code>Task</code> row with status
<code>QUEUED</code> and commit it to the database.</p>
diff --git a/atr/docs/overview-of-the-code.md b/atr/docs/overview-of-the-code.md
index 264c19b..3b93c71 100644
--- a/atr/docs/overview-of-the-code.md
+++ b/atr/docs/overview-of-the-code.md
@@ -54,6 +54,8 @@ The ATR user interface includes many HTML forms. We use
[WTForms](https://wtform
In addition to templates, we sometimes need to generate HTML programmatically
in Python. For this we use [htpy](https://htpy.dev/), another third party
library, for building HTML using Python syntax. The ATR
[`htm`](/ref/atr/htm.py) module extends htpy with a
[`Block`](/ref/atr/htm.py:Block) class that makes it easier to build complex
HTML structures incrementally. Using htpy means that we get type checking for
our HTML generation, and can compose HTML elements just like any other Python
[...]
+Refer to the [full user interface development guide](user-interface) for more
information about this topic.
+
## Scheduling and tasks
Many operations in ATR are too slow to run during an HTTP request, so we run
them asynchronously in background worker processes. The task scheduling system
in ATR is built from three components: a task queue stored in the SQLite
database, a worker manager that spawns and monitors worker processes, and the
worker processes themselves that claim and execute tasks.
diff --git a/atr/docs/storage-interface.html b/atr/docs/storage-interface.html
index 4170aef..98818c2 100644
--- a/atr/docs/storage-interface.html
+++ b/atr/docs/storage-interface.html
@@ -1,7 +1,7 @@
<h1 id="storage-interface">3.4. Storage interface</h1>
<p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer
guide</a></p>
<p><strong>Prev</strong>: <code>3.3.</code> <a href="database">Database</a></p>
-<p><strong>Next</strong>: <code>3.5.</code> <a href="build-processes">Build
processes</a></p>
+<p><strong>Next</strong>: <code>3.5.</code> <a href="user-interface">User
interface</a></p>
<p><strong>Sections</strong>:</p>
<ul>
<li><a href="#introduction">Introduction</a></li>
diff --git a/atr/docs/storage-interface.md b/atr/docs/storage-interface.md
index 5c47e48..fe7fad4 100644
--- a/atr/docs/storage-interface.md
+++ b/atr/docs/storage-interface.md
@@ -4,7 +4,7 @@
**Prev**: `3.3.` [Database](database)
-**Next**: `3.5.` [Build processes](build-processes)
+**Next**: `3.5.` [User interface](user-interface)
**Sections**:
diff --git a/atr/docs/user-interface.html b/atr/docs/user-interface.html
new file mode 100644
index 0000000..c7ce898
--- /dev/null
+++ b/atr/docs/user-interface.html
@@ -0,0 +1,112 @@
+<h1 id="user-interface">3.5. User interface</h1>
+<p><strong>Up</strong>: <code>3.</code> <a href="developer-guide">Developer
guide</a></p>
+<p><strong>Prev</strong>: <code>3.4.</code> <a
href="storage-interface">Storage interface</a></p>
+<p><strong>Next</strong>: <code>3.6.</code> <a href="build-processes">Build
processes</a></p>
+<p><strong>Sections</strong>:</p>
+<ul>
+<li><a href="#introduction">Introduction</a></li>
+<li><a href="#jinja2-templates">Jinja2 templates</a></li>
+<li><a href="#forms">Forms</a></li>
+<li><a href="#programmatic-html">Programmatic HTML</a></li>
+<li><a href="#the-htmblock-class">The htm.Block class</a></li>
+<li><a href="#how-a-route-renders-ui">How a route renders UI</a></li>
+</ul>
+<h2 id="introduction">Introduction</h2>
+<p>ATR uses server-side rendering almost exclusively: the server generates
HTML and sends it to the browser, which displays it. We try to avoid
client-side scripting, and in the rare cases where we need dynamic front end
components we use plain TypeScript without recourse to any third party
framework. (We have some JavaScript too, but we aim to use TypeScript only.)
Sometimes we incur a full page load where perhaps it would be more ideal to
update a fragment of the DOM in place, but page [...]
+<p>The UI is built from three main pieces: <a
href="https://jinja.palletsprojects.com/">Jinja2</a> for templates, <a
href="https://wtforms.readthedocs.io/">WTForms</a> for HTML forms, and <a
href="https://htpy.dev/">htpy</a> for programmatic HTML generation. We style
everything with <a href="https://getbootstrap.com/">Bootstrap</a>, which we
customize slightly.</p>
+<h2 id="jinja2-templates">Jinja2 templates</h2>
+<p>Templates live in <a
href="/ref/atr/templates/"><code>templates/</code></a>. Each template is a
Jinja2 file that defines HTML structure with placeholders for dynamic content.
Route handlers render templates by calling <a
href="/ref/atr/template.py:render"><code>template.render</code></a>, which is
an alias for <a
href="/ref/atr/template.py:render_sync"><code>template.render_sync</code></a>.
The function is asynchronous and takes a template name plus keyword arguments
for the template [...]
+<p>Here is an example from <a
href="/ref/atr/routes/keys.py:add"><code>routes/keys.py</code></a>:</p>
+<pre><code class="language-python">return await template.render(
+ "keys-add.html",
+ asf_id=session.uid,
+ user_committees=participant_of_committees,
+ form=form,
+ key_info=key_info,
+ algorithms=route.algorithms,
+)
+</code></pre>
+<p>The template receives these variables and can access them directly. If you
pass a variable called <code>form</code>, the template can use <code>{{ form
}}</code> to render it. <a
href="https://jinja.palletsprojects.com/en/stable/templates/#list-of-control-structures">Jinja2
has control structures</a> like <code>{% for %}</code> and <code>{% if
%}</code>, which you use when iterating over data or conditionally showing
content.</p>
+<p>Templates are loaded into memory at server startup by <a
href="/ref/atr/preload.py:setup_template_preloading"><code>preload.setup_template_preloading</code></a>.
This means that changing a template requires restarting the server in
development, which can be configured to happen automatically, but it also means
that rendering is fast because we never do a disk read during request handling.
The preloading scans <a href="/ref/atr/templates/"><code>templates/</code></a>
recursively and ca [...]
+<p>Template rendering happens in a thread pool to avoid blocking the async
event loop. The function <a
href="/ref/atr/template.py:_render_in_thread"><code>_render_in_thread</code></a>
uses <code>asyncio.to_thread</code> to execute Jinja2's synchronous
<code>render</code> method.</p>
+<h2 id="forms">Forms</h2>
+<p>HTML forms in ATR are handled by <a
href="https://wtforms.readthedocs.io/">WTForms</a>, accessed through our <a
href="/ref/atr/forms.py"><code>forms</code></a> module. Each form is a class
that inherits from <a
href="/ref/atr/forms.py:Typed"><code>forms.Typed</code></a>, which itself
inherits from <code>QuartForm</code> in <a
href="https://quart-wtf.readthedocs.io/">Quart-WTF</a>. Form fields are class
attributes created using helper functions from the <code>forms</code>
module.</p>
+<p>Here is a typical form definition from <a
href="/ref/atr/routes/keys.py:AddOpenPGPKeyForm"><code>routes/keys.py</code></a>:</p>
+<pre><code class="language-python">class AddOpenPGPKeyForm(forms.Typed):
+ public_key = forms.textarea(
+ "Public OpenPGP key",
+ placeholder="Paste your ASCII-armored public OpenPGP key here...",
+ description="Your public key should be in ASCII-armored format,
starting with"
+ ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+ )
+ selected_committees = forms.checkboxes(
+ "Associate key with committees",
+ description="Select the committees with which to associate your key.",
+ )
+ submit = forms.submit("Add OpenPGP key")
+</code></pre>
+<p>The helper functions like <a
href="/ref/atr/forms.py:textarea"><code>forms.textarea</code></a>, <a
href="/ref/atr/forms.py:checkboxes"><code>forms.checkboxes</code></a>, and <a
href="/ref/atr/forms.py:submit"><code>forms.submit</code></a> create WTForms
field objects with appropriate validators. The first argument is always the
label text. Optional fields take <code>optional=True</code>, and you can
provide placeholders, descriptions, and other field-specific options. If you do
not pa [...]
+<p>To use a form in a route, create it with <code>await
FormClass.create_form()</code>. For POST requests, pass <code>data=await
quart.request.form</code> to populate it with the submitted data. Then validate
with <code>await form.validate_on_submit()</code>. If validation passes, you
extract data from <code>form.field_name.data</code> and proceed. If validation
fails, re-render the template with the form object, which will then display
error messages.</p>
+<p>The <a href="/ref/atr/forms.py"><code>forms</code></a> module also provides
rendering functions that generate Bootstrap-styled HTML. The function <a
href="/ref/atr/forms.py:render_columns"><code>forms.render_columns</code></a>
creates a two-column layout with labels on the left and inputs on the right.
The function <a
href="/ref/atr/forms.py:render_simple"><code>forms.render_simple</code></a>
creates a simpler vertical layout. The function <a
href="/ref/atr/forms.py:render_table"><cod [...]
+<h2 id="programmatic-html">Programmatic HTML</h2>
+<p>Sometimes you need to generate HTML in Python rather than in a template.
For this we use <a href="https://htpy.dev/">htpy</a>, which provides a Python
API for building HTML elements. You import <code>htpy</code> and then use it
like this:</p>
+<pre><code class="language-python">import htpy
+
+element = htpy.div(".container")[
+ htpy.h1["Release Candidate"],
+ htpy.p["This is a release candidate."],
+]
+</code></pre>
+<p>The square brackets syntax is how htpy accepts children. The parentheses
syntax is for attributes. If you want a div with an id, you write
<code>htpy.div(id="content")</code>. If you want a div with a class, you can
use CSS selector syntax like <code>htpy.div(".my-class")</code> or you can use
<code>htpy.div(class_="my-class")</code>, remembering to use the underscore in
<code>class_</code>.</p>
+<p>You can nest elements arbitrarily, mix strings and elements, and pass lists
of elements. Converting an htpy element to a string renders it as HTML.
Templates can therefore render htpy elements directly by passing them as
variables.</p>
+<p>The htpy library provides type annotations for HTML elements. It does not
validate attribute names or values, so you can pass nonsensical attributes
without error. We plan to fix this by adding stricter types in our
<code>htm</code> wrapper. The main benefit to using <code>htpy</code> (via
<code>htm</code>) is having a clean Python API for HTML generation rather than
concatenating strings or using templating.</p>
+<h2 id="the-htmblock-class">The htm.Block class</h2>
+<p>The ATR <a href="/ref/atr/htm.py"><code>htm</code></a> module extends htpy
with a <a href="/ref/atr/htm.py:Block"><code>Block</code></a> class that makes
it easier to build complex HTML structures incrementally. You create a block,
append elements to it, and then collect them into a final element. Here is the
typical usage pattern:</p>
+<pre><code class="language-python">import atr.htm as htm
+
+div = htm.Block()
+div.h1["Release Information"]
+div.p["The release was created on ", release.created.isoformat(), "."]
+if release.released:
+ div.p["It was published on ", release.released.isoformat(), "."]
+return div.collect()
+</code></pre>
+<p>The block class provides properties for common HTML elements like
<code>h1</code>, <code>h2</code>, <code>p</code>, <code>div</code>,
<code>ul</code>, and so on. When you access these properties, you get back a <a
href="/ref/atr/htm.py:BlockElementCallable"><code>BlockElementCallable</code></a>,
which you can call to create an element with attributes or use square brackets
to add grandchildren of the block. The element is automatically appended to the
block's internal list of children.</p>
+<p>The <code>collect</code> method assembles all of the elements into a single
htpy element. If you created the block with an outer element like
<code>htm.Block(htpy.div(".container"))</code>, that element wraps all the
children. If you created the block with no outer element, <code>collect</code>
wraps everything in a div. You can also pass a <code>separator</code> argument
to <code>collect</code>, which inserts a text separator between elements.</p>
+<p>The block class is useful when you are building HTML in a loop or when you
have conditional elements. Instead of managing a list of elements manually, you
can let the block class do it for you: append elements as you go, and at the
end call <code>collect</code> to get the final result. This is cleaner than
concatenating strings or maintaining lists yourself.</p>
+<p>The block class also adds a <code>data-src</code> attribute to elements,
which records which function created the element. If you see an element in the
browser inspector with <code>data-src="atr.routes.keys:selected"</code>, you
know that it came from the <code>selected</code> function in
<code>routes/keys.py</code>. The source is extracted automatically using <a
href="/ref/atr/log.py:caller_name"><code>log.caller_name</code></a>.</p>
+<h2 id="how-a-route-renders-ui">How a route renders UI</h2>
+<p>A typical route that renders UI first authenticates the user, loads data
from the database, creates and validates a form if necessary, and renders a
template with the data and form. Here is a simplified example from <a
href="/ref/atr/routes/keys.py:add"><code>routes/keys.py</code></a>:</p>
+<pre><code class="language-python">@route.committer("/keys/add",
methods=["GET", "POST"])
+async def add(session: route.CommitterSession) -> str:
+ async with storage.write() as write:
+ participant_of_committees = await write.participant_of_committees()
+
+ committee_choices: forms.Choices = [
+ (c.name, c.display_name or c.name)
+ for c in participant_of_committees
+ ]
+
+ form = await AddOpenPGPKeyForm.create_form(
+ data=(await quart.request.form) if (quart.request.method == "POST")
else None
+ )
+ forms.choices(form.selected_committees, committee_choices)
+
+ if await form.validate_on_submit():
+ # Process the form data
+ # ...
+ await quart.flash(f"OpenPGP key added successfully.", "success")
+ form = await AddOpenPGPKeyForm.create_form()
+ forms.choices(form.selected_committees, committee_choices)
+
+ return await template.render(
+ "keys-add.html",
+ asf_id=session.uid,
+ user_committees=participant_of_committees,
+ form=form,
+ )
+</code></pre>
+<p>The route is decorated with <code>@route.committer</code>, which ensures
that the route fails before the function is even entered if authentication
fails. The function receives a <code>session</code> object, which is an
instance of <a
href="/ref/atr/route.py:CommitterSession"><code>route.CommitterSession</code></a>
with a range of useful properties and methods. The function then loads data,
creates a form, checks if the request is a POST, and either processes the form
or displays it. [...]
+<p>The template receives the form object and renders it by passing it to one
of the <code>forms.render_*</code> functions. We previously used Jinja2 macros
for this, but are migrating to the new rendering functions in Python (e.g. in
<a
href="/ref/atr/routes/distribution.py"><code>routes/distribution.py</code></a>
and <a href="/ref/atr/routes/ignores.py"><code>routes/ignores.py</code></a>).
The template also receives other data like <code>asf_id</code> and
<code>user_committees</code>, w [...]
+<p>If you use the programmatic rendering functions from <a
href="/ref/atr/forms.py"><code>forms</code></a>, you can skip the template
entirely. These functions return htpy elements, which you can combine with
other htpy elements and return directly from the route, which is often useful
for admin routes, for example. You can also use <a
href="/ref/atr/template.py:blank"><code>template.blank</code></a>, which
renders a minimal template with just a title and content area. This is useful
for [...]
+<p>Bootstrap CSS classes are applied automatically by the form rendering
functions. The functions use classes like <code>form-control</code>,
<code>form-select</code>, <code>btn-primary</code>, <code>is-invalid</code>,
and <code>invalid-feedback</code>. We currently use Bootstrap 5. If you
generate HTML manually with htpy, you can apply Bootstrap classes yourself by
using the CSS selector syntax like <code>htpy.div(".container")</code> or the
class attribute like <code>htpy.div(class_="c [...]
diff --git a/atr/docs/user-interface.md b/atr/docs/user-interface.md
new file mode 100644
index 0000000..2638e52
--- /dev/null
+++ b/atr/docs/user-interface.md
@@ -0,0 +1,157 @@
+# 3.5. User interface
+
+**Up**: `3.` [Developer guide](developer-guide)
+
+**Prev**: `3.4.` [Storage interface](storage-interface)
+
+**Next**: `3.6.` [Build processes](build-processes)
+
+**Sections**:
+
+* [Introduction](#introduction)
+* [Jinja2 templates](#jinja2-templates)
+* [Forms](#forms)
+* [Programmatic HTML](#programmatic-html)
+* [The htm.Block class](#the-htmblock-class)
+* [How a route renders UI](#how-a-route-renders-ui)
+
+## Introduction
+
+ATR uses server-side rendering almost exclusively: the server generates HTML
and sends it to the browser, which displays it. We try to avoid client-side
scripting, and in the rare cases where we need dynamic front end components we
use plain TypeScript without recourse to any third party framework. (We have
some JavaScript too, but we aim to use TypeScript only.) Sometimes we incur a
full page load where perhaps it would be more ideal to update a fragment of the
DOM in place, but page lo [...]
+
+The UI is built from three main pieces:
[Jinja2](https://jinja.palletsprojects.com/) for templates,
[WTForms](https://wtforms.readthedocs.io/) for HTML forms, and
[htpy](https://htpy.dev/) for programmatic HTML generation. We style everything
with [Bootstrap](https://getbootstrap.com/), which we customize slightly.
+
+## Jinja2 templates
+
+Templates live in [`templates/`](/ref/atr/templates/). Each template is a
Jinja2 file that defines HTML structure with placeholders for dynamic content.
Route handlers render templates by calling
[`template.render`](/ref/atr/template.py:render), which is an alias for
[`template.render_sync`](/ref/atr/template.py:render_sync). The function is
asynchronous and takes a template name plus keyword arguments for the template
variables.
+
+Here is an example from [`routes/keys.py`](/ref/atr/routes/keys.py:add):
+
+```python
+return await template.render(
+ "keys-add.html",
+ asf_id=session.uid,
+ user_committees=participant_of_committees,
+ form=form,
+ key_info=key_info,
+ algorithms=route.algorithms,
+)
+```
+
+The template receives these variables and can access them directly. If you
pass a variable called `form`, the template can use `{{ form }}` to render it.
[Jinja2 has control
structures](https://jinja.palletsprojects.com/en/stable/templates/#list-of-control-structures)
like `{% for %}` and `{% if %}`, which you use when iterating over data or
conditionally showing content.
+
+Templates are loaded into memory at server startup by
[`preload.setup_template_preloading`](/ref/atr/preload.py:setup_template_preloading).
This means that changing a template requires restarting the server in
development, which can be configured to happen automatically, but it also means
that rendering is fast because we never do a disk read during request handling.
The preloading scans [`templates/`](/ref/atr/templates/) recursively and caches
every file.
+
+Template rendering happens in a thread pool to avoid blocking the async event
loop. The function
[`_render_in_thread`](/ref/atr/template.py:_render_in_thread) uses
`asyncio.to_thread` to execute Jinja2's synchronous `render` method.
+
+## Forms
+
+HTML forms in ATR are handled by [WTForms](https://wtforms.readthedocs.io/),
accessed through our [`forms`](/ref/atr/forms.py) module. Each form is a class
that inherits from [`forms.Typed`](/ref/atr/forms.py:Typed), which itself
inherits from `QuartForm` in [Quart-WTF](https://quart-wtf.readthedocs.io/).
Form fields are class attributes created using helper functions from the
`forms` module.
+
+Here is a typical form definition from
[`routes/keys.py`](/ref/atr/routes/keys.py:AddOpenPGPKeyForm):
+
+```python
+class AddOpenPGPKeyForm(forms.Typed):
+ public_key = forms.textarea(
+ "Public OpenPGP key",
+ placeholder="Paste your ASCII-armored public OpenPGP key here...",
+ description="Your public key should be in ASCII-armored format,
starting with"
+ ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+ )
+ selected_committees = forms.checkboxes(
+ "Associate key with committees",
+ description="Select the committees with which to associate your key.",
+ )
+ submit = forms.submit("Add OpenPGP key")
+```
+
+The helper functions like [`forms.textarea`](/ref/atr/forms.py:textarea),
[`forms.checkboxes`](/ref/atr/forms.py:checkboxes), and
[`forms.submit`](/ref/atr/forms.py:submit) create WTForms field objects with
appropriate validators. The first argument is always the label text. Optional
fields take `optional=True`, and you can provide placeholders, descriptions,
and other field-specific options. If you do not pass `optional=True`, the field
is required by default. The [`forms.string`](/ref/ [...]
+
+To use a form in a route, create it with `await FormClass.create_form()`. For
POST requests, pass `data=await quart.request.form` to populate it with the
submitted data. Then validate with `await form.validate_on_submit()`. If
validation passes, you extract data from `form.field_name.data` and proceed. If
validation fails, re-render the template with the form object, which will then
display error messages.
+
+The [`forms`](/ref/atr/forms.py) module also provides rendering functions that
generate Bootstrap-styled HTML. The function
[`forms.render_columns`](/ref/atr/forms.py:render_columns) creates a two-column
layout with labels on the left and inputs on the right. The function
[`forms.render_simple`](/ref/atr/forms.py:render_simple) creates a simpler
vertical layout. The function
[`forms.render_table`](/ref/atr/forms.py:render_table) puts the form inside a
table. All three functions return ht [...]
+
+## Programmatic HTML
+
+Sometimes you need to generate HTML in Python rather than in a template. For
this we use [htpy](https://htpy.dev/), which provides a Python API for building
HTML elements. You import `htpy` and then use it like this:
+
+```python
+import htpy
+
+element = htpy.div(".container")[
+ htpy.h1["Release Candidate"],
+ htpy.p["This is a release candidate."],
+]
+```
+
+The square brackets syntax is how htpy accepts children. The parentheses
syntax is for attributes. If you want a div with an id, you write
`htpy.div(id="content")`. If you want a div with a class, you can use CSS
selector syntax like `htpy.div(".my-class")` or you can use
`htpy.div(class_="my-class")`, remembering to use the underscore in `class_`.
+
+You can nest elements arbitrarily, mix strings and elements, and pass lists of
elements. Converting an htpy element to a string renders it as HTML. Templates
can therefore render htpy elements directly by passing them as variables.
+
+The htpy library provides type annotations for HTML elements. It does not
validate attribute names or values, so you can pass nonsensical attributes
without error. We plan to fix this by adding stricter types in our `htm`
wrapper. The main benefit to using `htpy` (via `htm`) is having a clean Python
API for HTML generation rather than concatenating strings or using templating.
+
+## The htm.Block class
+
+The ATR [`htm`](/ref/atr/htm.py) module extends htpy with a
[`Block`](/ref/atr/htm.py:Block) class that makes it easier to build complex
HTML structures incrementally. You create a block, append elements to it, and
then collect them into a final element. Here is the typical usage pattern:
+
+```python
+import atr.htm as htm
+
+div = htm.Block()
+div.h1["Release Information"]
+div.p["The release was created on ", release.created.isoformat(), "."]
+if release.released:
+ div.p["It was published on ", release.released.isoformat(), "."]
+return div.collect()
+```
+
+The block class provides properties for common HTML elements like `h1`, `h2`,
`p`, `div`, `ul`, and so on. When you access these properties, you get back a
[`BlockElementCallable`](/ref/atr/htm.py:BlockElementCallable), which you can
call to create an element with attributes or use square brackets to add
grandchildren of the block. The element is automatically appended to the
block's internal list of children.
+
+The `collect` method assembles all of the elements into a single htpy element.
If you created the block with an outer element like
`htm.Block(htpy.div(".container"))`, that element wraps all the children. If
you created the block with no outer element, `collect` wraps everything in a
div. You can also pass a `separator` argument to `collect`, which inserts a
text separator between elements.
+
+The block class is useful when you are building HTML in a loop or when you
have conditional elements. Instead of managing a list of elements manually, you
can let the block class do it for you: append elements as you go, and at the
end call `collect` to get the final result. This is cleaner than concatenating
strings or maintaining lists yourself.
+
+The block class also adds a `data-src` attribute to elements, which records
which function created the element. If you see an element in the browser
inspector with `data-src="atr.routes.keys:selected"`, you know that it came
from the `selected` function in `routes/keys.py`. The source is extracted
automatically using [`log.caller_name`](/ref/atr/log.py:caller_name).
+
+## How a route renders UI
+
+A typical route that renders UI first authenticates the user, loads data from
the database, creates and validates a form if necessary, and renders a template
with the data and form. Here is a simplified example from
[`routes/keys.py`](/ref/atr/routes/keys.py:add):
+
+```python
[email protected]("/keys/add", methods=["GET", "POST"])
+async def add(session: route.CommitterSession) -> str:
+ async with storage.write() as write:
+ participant_of_committees = await write.participant_of_committees()
+
+ committee_choices: forms.Choices = [
+ (c.name, c.display_name or c.name)
+ for c in participant_of_committees
+ ]
+
+ form = await AddOpenPGPKeyForm.create_form(
+ data=(await quart.request.form) if (quart.request.method == "POST")
else None
+ )
+ forms.choices(form.selected_committees, committee_choices)
+
+ if await form.validate_on_submit():
+ # Process the form data
+ # ...
+ await quart.flash(f"OpenPGP key added successfully.", "success")
+ form = await AddOpenPGPKeyForm.create_form()
+ forms.choices(form.selected_committees, committee_choices)
+
+ return await template.render(
+ "keys-add.html",
+ asf_id=session.uid,
+ user_committees=participant_of_committees,
+ form=form,
+ )
+```
+
+The route is decorated with `@route.committer`, which ensures that the route
fails before the function is even entered if authentication fails. The function
receives a `session` object, which is an instance of
[`route.CommitterSession`](/ref/atr/route.py:CommitterSession) with a range of
useful properties and methods. The function then loads data, creates a form,
checks if the request is a POST, and either processes the form or displays it.
After successful processing, it creates a fresh [...]
+
+The template receives the form object and renders it by passing it to one of
the `forms.render_*` functions. We previously used Jinja2 macros for this, but
are migrating to the new rendering functions in Python (e.g. in
[`routes/distribution.py`](/ref/atr/routes/distribution.py) and
[`routes/ignores.py`](/ref/atr/routes/ignores.py)). The template also receives
other data like `asf_id` and `user_committees`, which it uses to display
information or make decisions about what to show.
+
+If you use the programmatic rendering functions from
[`forms`](/ref/atr/forms.py), you can skip the template entirely. These
functions return htpy elements, which you can combine with other htpy elements
and return directly from the route, which is often useful for admin routes, for
example. You can also use [`template.blank`](/ref/atr/template.py:blank), which
renders a minimal template with just a title and content area. This is useful
for simple pages that do not need the full templat [...]
+
+Bootstrap CSS classes are applied automatically by the form rendering
functions. The functions use classes like `form-control`, `form-select`,
`btn-primary`, `is-invalid`, and `invalid-feedback`. We currently use Bootstrap
5. If you generate HTML manually with htpy, you can apply Bootstrap classes
yourself by using the CSS selector syntax like `htpy.div(".container")` or the
class attribute like `htpy.div(class_="container")`.
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 0a4bc29..2813adb 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -144,7 +144,7 @@ async def add(session: route.CommitterSession) -> str:
committee_choices: forms.Choices = [(c.name, c.display_name or c.name) for
c in participant_of_committees]
form = await AddOpenPGPKeyForm.create_form(
- data=await quart.request.form if quart.request.method == "POST" else
None
+ data=(await quart.request.form) if (quart.request.method == "POST")
else None
)
forms.choices(form.selected_committees, committee_choices)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]