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 e4a07ad  Move user and voting routes, and update the documentation
e4a07ad is described below

commit e4a07adf8cfab1bca4a54d9dc9f73f6dcffda996
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Oct 28 16:40:44 2025 +0000

    Move user and voting routes, and update the documentation
---
 atr/docs/overview-of-the-code.html             |  4 +-
 atr/docs/overview-of-the-code.md               |  4 +-
 atr/docs/storage-interface.html                |  4 +-
 atr/docs/storage-interface.md                  |  4 +-
 atr/docs/user-interface.html                   | 10 ++--
 atr/docs/user-interface.md                     | 10 ++--
 atr/get/__init__.py                            |  4 ++
 atr/{routes => get}/user.py                    | 77 +++-----------------------
 atr/get/voting.py                              | 30 ++++++++++
 atr/post/__init__.py                           |  4 ++
 atr/post/user.py                               | 76 +++++++++++++++++++++++++
 atr/post/voting.py                             | 31 +++++++++++
 atr/routes/__init__.py                         | 70 -----------------------
 atr/server.py                                  | 10 +---
 atr/shared/__init__.py                         |  4 ++
 atr/shared/user.py                             | 27 +++++++++
 atr/{routes => shared}/voting.py               | 11 ++--
 atr/templates/check-selected-release-info.html |  2 +-
 atr/templates/voting-selected-revision.html    |  4 +-
 19 files changed, 210 insertions(+), 176 deletions(-)

diff --git a/atr/docs/overview-of-the-code.html 
b/atr/docs/overview-of-the-code.html
index 2dfb08a..615f22d 100644
--- a/atr/docs/overview-of-the-code.html
+++ b/atr/docs/overview-of-the-code.html
@@ -20,11 +20,11 @@
 </code></pre>
 <p>The <a 
href="/ref/atr/server.py:create_app"><code>server.create_app</code></a> 
function performs a lot of setup, and if you're interested in how the server 
works then you can read it and the functions it calls to understand the process 
further. In general, however, when developing ATR we do not make modifications 
at the ASFQuart, Quart, and Hypercorn levels very often.</p>
 <h2 id="routes-and-database">Routes and database</h2>
-<p>Users request ATR pages over HTTPS, and the ATR server processes those 
requests in route handlers. Most of those handlers are in <a 
href="/ref/atr/routes/"><code>routes</code></a>, but not all. What each handler 
does varies, of course, from handler to handler, but most perform at least one 
access to the ATR SQLite database.</p>
+<p>Users request ATR pages over HTTPS, and the ATR server processes those 
requests in route handlers. Most of those handlers are in <a 
href="/ref/atr/get/"><code>get</code></a> and <a 
href="/ref/atr/post/"><code>post</code></a>, with some helper code in <a 
href="/ref/atr/shared/"><code>shared</code></a>. What each handler does varies, 
of course, from handler to handler, but most perform at least one access to the 
ATR SQLite database.</p>
 <p>The path of the SQLite database is configured in <a 
href="/ref/atr/config.py:SQLITE_DB_PATH"><code>config.AppConfig.SQLITE_DB_PATH</code></a>
 by default, and will usually appear as <code>state/atr.db</code> with related 
<code>shm</code> and <code>wal</code> files. We do not expect ATR to have so 
many users that we need to scale beyond SQLite.</p>
 <p>We use <a href="https://sqlmodel.tiangolo.com/";>SQLModel</a>, an ORM 
utilising <a href="https://docs.pydantic.dev/latest/";>Pydantic</a> and <a 
href="https://www.sqlalchemy.org/";>SQLAlchemy</a>, to create Python models for 
the ATR database. The core models file is <a 
href="/ref/atr/models/sql.py"><code>models.sql</code></a>. The most important 
individual SQLite models in this module are <a 
href="/ref/atr/models/sql.py:Committee"><code>Committee</code></a>, <a 
href="/ref/atr/models/sql. [...]
 <p>It is technically possible to interact with SQLite directly, but we do not 
do that in the ATR source. We use various interfaces in <a 
href="/ref/atr/db/__init__.py"><code>db</code></a> for reads, and interfaces in 
<a href="/ref/atr/storage/"><code>storage</code></a> for writes. We plan to 
move the <code>db</code> code into <code>storage</code> too eventually, because 
<code>storage</code> is designed to have read components and write components. 
There is also a legacy <a href="/ref/atr [...]
-<p>These three interfaces, <a href="/ref/atr/routes/"><code>routes</code></a>, 
<a href="/ref/atr/models/sql.py"><code>models.sql</code></a>, and <a 
href="/ref/atr/storage/"><code>storage</code></a>, are where the majority of 
activity happens when developing ATR.</p>
+<p>These interfaces, including route handlers in <a 
href="/ref/atr/get/"><code>get</code></a>, <a 
href="/ref/atr/post/"><code>post</code></a>, and <a 
href="/ref/atr/shared/"><code>shared</code></a>, along with <a 
href="/ref/atr/models/sql.py"><code>models.sql</code></a> and <a 
href="/ref/atr/storage/"><code>storage</code></a>, are where the majority of 
activity happens when developing ATR.</p>
 <h2 id="user-interface">User interface</h2>
 <p>ATR provides a web interface for users to interact with the platform, and 
the implementation of that interface is split across several modules. The web 
interface uses server-side rendering almost entirely, where HTML is generated 
on the server and sent to the browser.</p>
 <p>The template system in ATR is <a 
href="https://jinja.palletsprojects.com/";>Jinja2</a>, always accessed through 
the ATR <a href="/ref/atr/template.py"><code>template</code></a> module. 
Template files in Jinja2 syntax are stored in <a 
href="/ref/atr/templates/"><code>templates/</code></a>, and route handlers 
render them using the asynchronous <a 
href="/ref/atr/template.py:render"><code>template.render</code></a> 
function.</p>
diff --git a/atr/docs/overview-of-the-code.md b/atr/docs/overview-of-the-code.md
index b8b9718..c5d568e 100644
--- a/atr/docs/overview-of-the-code.md
+++ b/atr/docs/overview-of-the-code.md
@@ -32,7 +32,7 @@ The [`server.create_app`](/ref/atr/server.py:create_app) 
function performs a lot
 
 ## Routes and database
 
-Users request ATR pages over HTTPS, and the ATR server processes those 
requests in route handlers. Most of those handlers are in 
[`routes`](/ref/atr/routes/), but not all. What each handler does varies, of 
course, from handler to handler, but most perform at least one access to the 
ATR SQLite database.
+Users request ATR pages over HTTPS, and the ATR server processes those 
requests in route handlers. Most of those handlers are in 
[`get`](/ref/atr/get/) and [`post`](/ref/atr/post/), with some helper code in 
[`shared`](/ref/atr/shared/). What each handler does varies, of course, from 
handler to handler, but most perform at least one access to the ATR SQLite 
database.
 
 The path of the SQLite database is configured in 
[`config.AppConfig.SQLITE_DB_PATH`](/ref/atr/config.py:SQLITE_DB_PATH) by 
default, and will usually appear as `state/atr.db` with related `shm` and `wal` 
files. We do not expect ATR to have so many users that we need to scale beyond 
SQLite.
 
@@ -40,7 +40,7 @@ We use [SQLModel](https://sqlmodel.tiangolo.com/), an ORM 
utilising [Pydantic](h
 
 It is technically possible to interact with SQLite directly, but we do not do 
that in the ATR source. We use various interfaces in 
[`db`](/ref/atr/db/__init__.py) for reads, and interfaces in 
[`storage`](/ref/atr/storage/) for writes. We plan to move the `db` code into 
`storage` too eventually, because `storage` is designed to have read components 
and write components. There is also a legacy 
[`db.interaction`](/ref/atr/db/interaction.py) module which we plan to migrate 
into `storage`.
 
-These three interfaces, [`routes`](/ref/atr/routes/), 
[`models.sql`](/ref/atr/models/sql.py), and [`storage`](/ref/atr/storage/), are 
where the majority of activity happens when developing ATR.
+These interfaces, including route handlers in [`get`](/ref/atr/get/), 
[`post`](/ref/atr/post/), and [`shared`](/ref/atr/shared/), along with 
[`models.sql`](/ref/atr/models/sql.py) and [`storage`](/ref/atr/storage/), are 
where the majority of activity happens when developing ATR.
 
 ## User interface
 
diff --git a/atr/docs/storage-interface.html b/atr/docs/storage-interface.html
index 539ccd5..184e070 100644
--- a/atr/docs/storage-interface.html
+++ b/atr/docs/storage-interface.html
@@ -18,7 +18,7 @@
 <h2 id="how-do-we-read-from-storage">How do we read from storage?</h2>
 <p>Reading from storage is a work in progress. There are some existing 
methods, but most of the functionality is currently in <code>db</code> or 
<code>db.interaction</code>, and much work is required to migrate this to the 
storage interface. We have given this less priority because reads are generally 
safe, with the exception of a few components such as user tokens, which should 
be given greater migration priority.</p>
 <h2 id="how-do-we-write-to-storage">How do we write to storage?</h2>
-<p>To write to storage we open a write session, request specific permissions, 
use the exposed functionality, and then handle the outcome. Here is an actual 
example from <a 
href="/ref/atr/routes/start.py"><code>routes/start.py</code></a>:</p>
+<p>To write to storage we open a write session, request specific permissions, 
use the exposed functionality, and then handle the outcome. Here is an actual 
example from <a 
href="/ref/atr/post/start.py"><code>post/start.py</code></a>:</p>
 <pre><code class="language-python">async with storage.write(session) as write:
     wacp = await write.as_project_committee_participant(project_name)
     new_release, _project = await wacp.release.start(project_name, version)
@@ -84,7 +84,7 @@ class CommitteeMember(CommitteeParticipant):
 <h2 id="how-do-we-use-outcomes">How do we use outcomes?</h2>
 <p>Consider using <strong>outcome types</strong> from <a 
href="/ref/atr/storage/outcome.py"><code>storage.outcome</code></a> when 
returning results from writer methods. Outcomes let you represent both success 
and failure without raising exceptions, which gives callers flexibility in how 
they handle errors.</p>
 <p>An <a 
href="/ref/atr/storage/outcome.py:Outcome"><code>Outcome[T]</code></a> is 
either a <a 
href="/ref/atr/storage/outcome.py:Result"><code>Result[T]</code></a> wrapping a 
successful value, or an <a 
href="/ref/atr/storage/outcome.py:Error"><code>Error[T]</code></a> wrapping an 
exception. You can check which it is with the <code>ok</code> property or 
pattern matching, extract the value with <code>result_or_raise()</code>, or 
extract the error with <code>error_or_raise()</code>.</p>
-<p>Here is an example from <a 
href="/ref/atr/routes/keys.py"><code>routes/keys.py</code></a> that processes 
multiple keys and collects outcomes:</p>
+<p>Here is an example from <a 
href="/ref/atr/post/keys.py"><code>post/keys.py</code></a> that processes 
multiple keys and collects outcomes:</p>
 <pre><code class="language-python">async with storage.write() as write:
     wacm = write.as_committee_member(selected_committee)
     outcomes = await wacm.keys.ensure_associated(keys_text)
diff --git a/atr/docs/storage-interface.md b/atr/docs/storage-interface.md
index 675b876..97b508e 100644
--- a/atr/docs/storage-interface.md
+++ b/atr/docs/storage-interface.md
@@ -29,7 +29,7 @@ Reading from storage is a work in progress. There are some 
existing methods, but
 
 ## How do we write to storage?
 
-To write to storage we open a write session, request specific permissions, use 
the exposed functionality, and then handle the outcome. Here is an actual 
example from [`routes/start.py`](/ref/atr/routes/start.py):
+To write to storage we open a write session, request specific permissions, use 
the exposed functionality, and then handle the outcome. Here is an actual 
example from [`post/start.py`](/ref/atr/post/start.py):
 
 ```python
 async with storage.write(session) as write:
@@ -116,7 +116,7 @@ Consider using **outcome types** from 
[`storage.outcome`](/ref/atr/storage/outco
 
 An [`Outcome[T]`](/ref/atr/storage/outcome.py:Outcome) is either a 
[`Result[T]`](/ref/atr/storage/outcome.py:Result) wrapping a successful value, 
or an [`Error[T]`](/ref/atr/storage/outcome.py:Error) wrapping an exception. 
You can check which it is with the `ok` property or pattern matching, extract 
the value with `result_or_raise()`, or extract the error with 
`error_or_raise()`.
 
-Here is an example from [`routes/keys.py`](/ref/atr/routes/keys.py) that 
processes multiple keys and collects outcomes:
+Here is an example from [`post/keys.py`](/ref/atr/post/keys.py) that processes 
multiple keys and collects outcomes:
 
 ```python
 async with storage.write() as write:
diff --git a/atr/docs/user-interface.html b/atr/docs/user-interface.html
index 3c9a0f0..ceb2577 100644
--- a/atr/docs/user-interface.html
+++ b/atr/docs/user-interface.html
@@ -16,7 +16,7 @@
 <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>
+<p>Here is an example from <a 
href="/ref/atr/get/keys.py:add"><code>get/keys.py</code></a>:</p>
 <pre><code class="language-python">return await template.render(
     "keys-add.html",
     asf_id=session.uid,
@@ -31,7 +31,7 @@
 <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>
+<p>Here is a typical form definition from <a 
href="/ref/atr/shared/keys.py:AddOpenPGPKeyForm"><code>shared/keys.py</code></a>:</p>
 <pre><code class="language-python">class AddOpenPGPKeyForm(forms.Typed):
     public_key = forms.textarea(
         "Public OpenPGP key",
@@ -74,9 +74,9 @@ return div.collect()
 <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>
+<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.get.keys:keys"</code>, you know that 
it came from the <code>keys</code> function in <code>get/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>
+<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/get/keys.py:add"><code>get/keys.py</code></a>:</p>
 <pre><code class="language-python">@route.committer("/keys/add", 
methods=["GET", "POST"])
 async def add(session: route.CommitterSession) -&gt; str:
     async with storage.write() as write:
@@ -107,6 +107,6 @@ async def add(session: route.CommitterSession) -&gt; str:
     )
 </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>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/get/distribution.py"><code>get/distribution.py</code></a> and 
<a href="/ref/atr/get/ignores.py"><code>get/ignores.py</code></a>). The 
template also receives other data like <code>asf_id</code> and 
<code>user_committees</code>, which it uses [...]
 <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
index 82815ad..d6e1aec 100644
--- a/atr/docs/user-interface.md
+++ b/atr/docs/user-interface.md
@@ -25,7 +25,7 @@ The UI is built from three main pieces: 
[Jinja2](https://jinja.palletsprojects.c
 
 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):
+Here is an example from [`get/keys.py`](/ref/atr/get/keys.py:add):
 
 ```python
 return await template.render(
@@ -48,7 +48,7 @@ Template rendering happens in a thread pool to avoid blocking 
the async event lo
 
 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):
+Here is a typical form definition from 
[`shared/keys.py`](/ref/atr/shared/keys.py:AddOpenPGPKeyForm):
 
 ```python
 class AddOpenPGPKeyForm(forms.Typed):
@@ -111,11 +111,11 @@ The `collect` method assembles all of the elements into a 
single htpy element. I
 
 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).
+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.get.keys:keys"`, you know that it came from the 
`keys` function in `get/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):
+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 
[`get/keys.py`](/ref/atr/get/keys.py:add):
 
 ```python
 @route.committer("/keys/add", methods=["GET", "POST"])
@@ -150,7 +150,7 @@ async def add(session: route.CommitterSession) -> str:
 
 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.
+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 
[`get/distribution.py`](/ref/atr/get/distribution.py) and 
[`get/ignores.py`](/ref/atr/get/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 [...]
 
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 2340899..dd91e3b 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -44,7 +44,9 @@ import atr.get.sbom as sbom
 import atr.get.start as start
 import atr.get.tokens as tokens
 import atr.get.upload as upload
+import atr.get.user as user
 import atr.get.vote as vote
+import atr.get.voting as voting
 
 ROUTES_MODULE: Final[Literal[True]] = True
 
@@ -74,5 +76,7 @@ __all__ = [
     "start",
     "tokens",
     "upload",
+    "user",
     "vote",
+    "voting",
 ]
diff --git a/atr/routes/user.py b/atr/get/user.py
similarity index 56%
rename from atr/routes/user.py
rename to atr/get/user.py
index 84476ae..539afa2 100644
--- a/atr/routes/user.py
+++ b/atr/get/user.py
@@ -15,34 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from __future__ import annotations
-
-from typing import TYPE_CHECKING
-
 import quart
 
+import atr.blueprints.get as get
 import atr.forms as forms
 import atr.htm as htm
-import atr.route as route
+import atr.shared as shared
 import atr.template as template
 import atr.util as util
+import atr.web as web
 
-if TYPE_CHECKING:
-    import werkzeug.wrappers.response as response
-
-
-class CacheForm(forms.Typed):
-    cache_submit = forms.submit("Cache me!")
-
-
-class DeleteCacheForm(forms.Typed):
-    delete_submit = forms.submit("Delete my cache")
 
-
[email protected]("/user/cache", methods=["GET"])
-async def cache_get(session: route.CommitterSession) -> str:
-    cache_form = await CacheForm.create_form()
-    delete_cache_form = await DeleteCacheForm.create_form()
[email protected]("/user/cache")
+async def cache_get(session: web.Committer) -> str:
+    cache_form = await shared.user.CacheForm.create_form()
+    delete_cache_form = await shared.user.DeleteCacheForm.create_form()
 
     cache_data = await util.session_cache_read()
     user_cached = session.uid in cache_data
@@ -99,53 +86,3 @@ async def cache_get(session: route.CommitterSession) -> str:
         block.append(cache_form_element)
 
     return await template.blank("Session cache management", 
content=block.collect())
-
-
[email protected]("/user/cache", methods=["POST"])
-async def session_post(session: route.CommitterSession) -> response.Response:
-    form_data = await quart.request.form
-
-    cache_form = await CacheForm.create_form(data=form_data)
-    delete_cache_form = await DeleteCacheForm.create_form(data=form_data)
-
-    if cache_form.cache_submit.data:
-        await _cache_session(session)
-        await quart.flash("Your session has been cached successfully", 
"success")
-    elif delete_cache_form.delete_submit.data:
-        await _delete_session_cache(session)
-        await quart.flash("Your cached session has been deleted", "success")
-    else:
-        await quart.flash("Invalid form submission", "error")
-
-    return await session.redirect(cache_get)
-
-
-async def _cache_session(session: route.CommitterSession) -> None:
-    cache_data = await util.session_cache_read()
-
-    session_data = {
-        "uid": session.uid,
-        "dn": getattr(session, "dn", None),
-        "fullname": getattr(session, "fullname", None),
-        "email": getattr(session, "email", f"{session.uid}@apache.org"),
-        "isMember": getattr(session, "isMember", False),
-        "isChair": getattr(session, "isChair", False),
-        "isRoot": getattr(session, "isRoot", False),
-        "pmcs": getattr(session, "committees", []),
-        "projects": getattr(session, "projects", []),
-        "mfa": getattr(session, "mfa", False),
-        "roleaccount": getattr(session, "isRole", False),
-        "metadata": getattr(session, "metadata", {}),
-    }
-
-    cache_data[session.uid] = session_data
-
-    await util.session_cache_write(cache_data)
-
-
-async def _delete_session_cache(session: route.CommitterSession) -> None:
-    cache_data = await util.session_cache_read()
-
-    if session.uid in cache_data:
-        del cache_data[session.uid]
-        await util.session_cache_write(cache_data)
diff --git a/atr/get/voting.py b/atr/get/voting.py
new file mode 100644
index 0000000..2d4dc05
--- /dev/null
+++ b/atr/get/voting.py
@@ -0,0 +1,30 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.get as get
+import atr.shared as shared
+import atr.web as web
+
+
[email protected]("/voting/<project_name>/<version_name>/<revision>")
+async def selected_revision(
+    session: web.Committer, project_name: str, version_name: str, revision: str
+) -> response.Response | str:
+    return await shared.voting.selected_revision(session, project_name, 
version_name, revision)
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index 7e5238d..576ce08 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -32,7 +32,9 @@ import atr.post.sbom as sbom
 import atr.post.start as start
 import atr.post.tokens as tokens
 import atr.post.upload as upload
+import atr.post.user as user
 import atr.post.vote as vote
+import atr.post.voting as voting
 
 ROUTES_MODULE: Final[Literal[True]] = True
 
@@ -52,5 +54,7 @@ __all__ = [
     "start",
     "tokens",
     "upload",
+    "user",
     "vote",
+    "voting",
 ]
diff --git a/atr/post/user.py b/atr/post/user.py
new file mode 100644
index 0000000..8db3963
--- /dev/null
+++ b/atr/post/user.py
@@ -0,0 +1,76 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+import quart
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.post as post
+import atr.get as get
+import atr.shared as shared
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/user/cache")
+async def session_post(session: web.Committer) -> response.Response:
+    form_data = await quart.request.form
+
+    cache_form = await shared.user.CacheForm.create_form(data=form_data)
+    delete_cache_form = await 
shared.user.DeleteCacheForm.create_form(data=form_data)
+
+    if cache_form.cache_submit.data:
+        await _cache_session(session)
+        await quart.flash("Your session has been cached successfully", 
"success")
+    elif delete_cache_form.delete_submit.data:
+        await _delete_session_cache(session)
+        await quart.flash("Your cached session has been deleted", "success")
+    else:
+        await quart.flash("Invalid form submission", "error")
+
+    return await session.redirect(get.user.cache_get)
+
+
+async def _cache_session(session: web.Committer) -> None:
+    cache_data = await util.session_cache_read()
+
+    session_data = {
+        "uid": session.uid,
+        "dn": getattr(session, "dn", None),
+        "fullname": getattr(session, "fullname", None),
+        "email": getattr(session, "email", f"{session.uid}@apache.org"),
+        "isMember": getattr(session, "isMember", False),
+        "isChair": getattr(session, "isChair", False),
+        "isRoot": getattr(session, "isRoot", False),
+        "pmcs": getattr(session, "committees", []),
+        "projects": getattr(session, "projects", []),
+        "mfa": getattr(session, "mfa", False),
+        "roleaccount": getattr(session, "isRole", False),
+        "metadata": getattr(session, "metadata", {}),
+    }
+
+    cache_data[session.uid] = session_data
+
+    await util.session_cache_write(cache_data)
+
+
+async def _delete_session_cache(session: web.Committer) -> None:
+    cache_data = await util.session_cache_read()
+
+    if session.uid in cache_data:
+        del cache_data[session.uid]
+        await util.session_cache_write(cache_data)
diff --git a/atr/post/voting.py b/atr/post/voting.py
new file mode 100644
index 0000000..69dd0f3
--- /dev/null
+++ b/atr/post/voting.py
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.post as post
+import atr.shared as shared
+import atr.web as web
+
+
[email protected]("/voting/<project_name>/<version_name>/<revision>")
+async def selected_revision(
+    session: web.Committer, project_name: str, version_name: str, revision: str
+) -> response.Response | str:
+    """Show the vote initiation form for a release."""
+    return await shared.voting.selected_revision(session, project_name, 
version_name, revision)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
deleted file mode 100644
index a8e1b85..0000000
--- a/atr/routes/__init__.py
+++ /dev/null
@@ -1,70 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-import atr.routes.user as user
-import atr.routes.voting as voting
-
-__all__ = [
-    "user",
-    "voting",
-]
-
-
-# Export data for a custom linter script
-def _export_routes() -> None:
-    import asyncio
-
-    async def _export_routes_async() -> None:
-        """Export all routes to a JSON file for static analysis."""
-        import json
-        import sys
-
-        import aiofiles
-
-        route_paths: list[str] = []
-        current_module = sys.modules[__name__]
-
-        for module_name in dir(current_module):
-            if module_name.startswith("_"):
-                # Not intended for external use
-                continue
-
-            module = getattr(current_module, module_name)
-            if not hasattr(module, "__file__"):
-                # Not a module
-                continue
-
-            # Get all callable interfaces that do not begin with an underscore
-            for attr_name in dir(module):
-                if attr_name.startswith("_"):
-                    # Not intended for external use
-                    continue
-                if not callable(getattr(module, attr_name)):
-                    # Not callable
-                    continue
-                route_path = f"{module_name}.{attr_name}"
-                route_paths.append(route_path)
-
-        async with aiofiles.open("routes.json", "w", encoding="utf-8") as f:
-            await f.write(json.dumps(route_paths, indent=2))
-
-    loop = asyncio.get_event_loop()
-    loop.run_until_complete(_export_routes_async())
-
-
-_export_routes()
-del _export_routes
diff --git a/atr/server.py b/atr/server.py
index 1129ed6..12ef32b 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -23,7 +23,6 @@ import datetime
 import os
 import queue
 from collections.abc import Iterable
-from types import ModuleType
 from typing import Any
 
 import asfquart
@@ -132,7 +131,6 @@ def app_setup_context(app: base.QuartApp) -> None:
         import atr.mapping as mapping
         import atr.metadata as metadata
         import atr.post as post
-        import atr.routes as routes
 
         return {
             "admin": admin,
@@ -144,7 +142,6 @@ def app_setup_context(app: base.QuartApp) -> None:
             "is_viewing_as_admin_fn": util.is_user_viewing_as_admin,
             "is_committee_member_fn": user.is_committee_member,
             "post": post,
-            "routes": routes,
             "static_url": util.static_url,
             "unfinished_releases_fn": interaction.unfinished_releases,
             # "user_committees_fn": interaction.user_committees,
@@ -385,10 +382,7 @@ def main() -> None:
     app.run(port=8080, ssl_keyfile="key.pem", ssl_certfile="cert.pem")
 
 
-def register_routes(app: base.QuartApp) -> ModuleType:
-    # NOTE: These imports are for their side effects only
-    import atr.routes as routes
-
+def register_routes(app: base.QuartApp) -> None:
     # Add a global error handler to show helpful error messages with tracebacks
     @app.errorhandler(Exception)
     async def handle_any_exception(error: Exception) -> Any:
@@ -424,8 +418,6 @@ def register_routes(app: base.QuartApp) -> ModuleType:
             return quart.jsonify({"error": "404 Not Found"}), 404
         return await template.render("notfound.html", error="404 Not Found", 
traceback="", status_code=404), 404
 
-    return routes
-
 
 # FIXME: when running in SSL mode, you will receive these exceptions upon 
termination at times:
 #        ssl.SSLError: [SSL: APPLICATION_DATA_AFTER_CLOSE_NOTIFY] application 
data after close notify (_ssl.c:2706)
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index d7c5835..14f86a5 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -36,7 +36,9 @@ import atr.shared.resolve as resolve
 import atr.shared.start as start
 import atr.shared.tokens as tokens
 import atr.shared.upload as upload
+import atr.shared.user as user
 import atr.shared.vote as vote
+import atr.shared.voting as voting
 import atr.storage as storage
 import atr.template as template
 import atr.util as util
@@ -194,5 +196,7 @@ __all__ = [
     "start",
     "tokens",
     "upload",
+    "user",
     "vote",
+    "voting",
 ]
diff --git a/atr/shared/user.py b/atr/shared/user.py
new file mode 100644
index 0000000..e9dbeff
--- /dev/null
+++ b/atr/shared/user.py
@@ -0,0 +1,27 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+import atr.forms as forms
+
+
+class CacheForm(forms.Typed):
+    cache_submit = forms.submit("Cache me!")
+
+
+class DeleteCacheForm(forms.Typed):
+    delete_submit = forms.submit("Delete my cache")
diff --git a/atr/routes/voting.py b/atr/shared/voting.py
similarity index 97%
rename from atr/routes/voting.py
rename to atr/shared/voting.py
index 0e84a10..f0057ac 100644
--- a/atr/routes/voting.py
+++ b/atr/shared/voting.py
@@ -30,11 +30,11 @@ import atr.get.compose as compose
 import atr.get.vote as vote
 import atr.log as log
 import atr.models.sql as sql
-import atr.route as route
 import atr.storage as storage
 import atr.template as template
 import atr.user as user
 import atr.util as util
+import atr.web as web
 
 
 class VoteInitiateForm(forms.Typed):
@@ -54,9 +54,8 @@ class VoteInitiateForm(forms.Typed):
     submit = forms.submit("Send vote email")
 
 
[email protected]("/voting/<project_name>/<version_name>/<revision>", 
methods=["GET", "POST"])
 async def selected_revision(
-    session: route.CommitterSession, project_name: str, version_name: str, 
revision: str
+    session: web.Committer, project_name: str, version_name: str, revision: str
 ) -> response.Response | str:
     """Show the vote initiation form for a release."""
     await session.check_access(project_name)
@@ -113,8 +112,8 @@ async def selected_revision(
 async def start_vote_manual(
     release: sql.Release,
     selected_revision_number: str,
-    session: route.CommitterSession,
-    data: db.Session,
+    session: web.Committer,
+    _data: db.Session,
 ) -> response.Response | str:
     async with storage.write(session) as write:
         wacp = await 
write.as_project_committee_participant(release.project_name)
@@ -196,7 +195,7 @@ async def _selected_revision_data(
     version_name: str,
     revision: str,
     data: db.Session,
-    session: route.CommitterSession,
+    session: web.Committer,
 ) -> response.Response | str | VoteInitiateForm:
     committee = release.committee
     if committee is None:
diff --git a/atr/templates/check-selected-release-info.html 
b/atr/templates/check-selected-release-info.html
index 65efe3b..09d1b6c 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -59,7 +59,7 @@
            class="btn btn-secondary"><i class="bi bi-clock-history me-1"></i> 
Revisions</a>
         {% if revision_number %}
           {% if has_files and (not strict_checking_errors) %}
-            <a href="{{ as_url(routes.voting.selected_revision, 
project_name=release.project.name, version_name=release.version, 
revision=revision_number) }}"
+            <a href="{{ as_url(get.voting.selected_revision, 
project_name=release.project.name, version_name=release.version, 
revision=revision_number) }}"
                title="Start a vote on this draft"
                class="btn btn-success"><i class="bi bi-check-circle me-1"></i> 
Start voting</a>
           {% else %}
diff --git a/atr/templates/voting-selected-revision.html 
b/atr/templates/voting-selected-revision.html
index d7436bd..ada9580 100644
--- a/atr/templates/voting-selected-revision.html
+++ b/atr/templates/voting-selected-revision.html
@@ -49,7 +49,7 @@
     <form method="post"
           id="vote-initiate-form"
           class="atr-canary py-4 px-5"
-          action="{{ as_url(routes.voting.selected_revision, 
project_name=release.project.name, version_name=release.version, 
revision=revision_number) }}"
+          action="{{ as_url(post.voting.selected_revision, 
project_name=release.project.name, version_name=release.version, 
revision=revision_number) }}"
           novalidate>
       {{ form.hidden_tag() }}
 
@@ -143,7 +143,7 @@
   {% elif manual_vote_process_form %}
     <p>This release has the manual vote process enabled. Press the button 
below to start a vote.</p>
     <form method="post"
-          action="{{ as_url(routes.voting.selected_revision, 
project_name=release.project.name, version_name=release.version, 
revision=revision_number) }}"
+          action="{{ as_url(post.voting.selected_revision, 
project_name=release.project.name, version_name=release.version, 
revision=revision_number) }}"
           novalidate>
       {{ manual_vote_process_form.hidden_tag() }}
       <div>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to