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 8f643e0  Move compose, distribution, and vote routes
8f643e0 is described below

commit 8f643e0d9d78c53f0f39c832341b1a80525147c5
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Oct 27 15:57:00 2025 +0000

    Move compose, distribution, and vote routes
---
 atr/get/__init__.py                               |   5 +-
 atr/get/compose.py                                |  43 ++++
 atr/get/distribution.py                           | 128 ++++++++++++
 atr/{routes => get}/vote.py                       |  82 +-------
 atr/post/__init__.py                              |   4 +-
 atr/post/distribution.py                          |  95 +++++++++
 atr/post/vote.py                                  |  85 ++++++++
 atr/routes/__init__.py                            |   6 -
 atr/routes/compose.py                             | 162 ---------------
 atr/routes/draft.py                               |   2 +-
 atr/routes/keys.py                                |   2 +-
 atr/routes/mapping.py                             |  13 +-
 atr/routes/resolve.py                             |   4 +-
 atr/routes/start.py                               |   2 +-
 atr/routes/upload.py                              |   2 +-
 atr/routes/voting.py                              |   4 +-
 atr/shared/__init__.py                            | 133 +++++++++++-
 atr/{routes => shared}/distribution.py            | 237 ++++------------------
 atr/{post/__init__.py => shared/vote.py}          |  13 +-
 atr/templates/check-selected-candidate-forms.html |   2 +-
 atr/templates/check-selected.html                 |   2 +-
 atr/templates/draft-tools.html                    |   2 +-
 atr/templates/finish-selected.html                |   2 +-
 atr/templates/phase-view.html                     |   4 +-
 atr/templates/release-select.html                 |   4 +-
 atr/templates/report-selected-path.html           |   4 +-
 atr/templates/resolve-manual.html                 |   2 +-
 atr/templates/resolve-tabulated.html              |   2 +-
 atr/templates/revisions-selected.html             |   2 +-
 atr/templates/upload-selected.html                |   2 +-
 atr/templates/voting-selected-revision.html       |   4 +-
 31 files changed, 575 insertions(+), 479 deletions(-)

diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index cb099e2..729f0db 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -20,9 +20,12 @@ from typing import Final, Literal
 import atr.get.announce as announce
 import atr.get.candidate as candidate
 import atr.get.committees as committees
+import atr.get.compose as compose
+import atr.get.distribution as distribution
+import atr.get.vote as vote
 
 from .example_test import respond as example_test
 
 ROUTES_MODULE: Final[Literal[True]] = True
 
-__all__ = ["announce", "candidate", "committees", "example_test"]
+__all__ = ["announce", "candidate", "committees", "compose", "distribution", 
"example_test", "vote"]
diff --git a/atr/get/compose.py b/atr/get/compose.py
new file mode 100644
index 0000000..7c51a69
--- /dev/null
+++ b/atr/get/compose.py
@@ -0,0 +1,43 @@
+# 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 asfquart.base as base
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.get as get
+import atr.db as db
+import atr.models.sql as sql
+import atr.routes.mapping as mapping
+import atr.shared as shared
+import atr.web as web
+
+
[email protected]("/compose/<project_name>/<version_name>")
+async def selected(session: web.Committer, project_name: str, version_name: 
str) -> response.Response | str:
+    """Show the contents of the release candidate draft."""
+    await session.check_access(project_name)
+
+    async with db.session() as data:
+        release = await data.release(
+            project_name=project_name,
+            version=version_name,
+            _committee=True,
+            _project_release_policy=True,
+        ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
+    if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+        return await mapping.release_as_redirect(session, release)
+    return await shared.check(session, release)
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
new file mode 100644
index 0000000..80738a2
--- /dev/null
+++ b/atr/get/distribution.py
@@ -0,0 +1,128 @@
+# 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.
+
+from __future__ import annotations
+
+import htpy
+
+import atr.blueprints.get as get
+import atr.db as db
+import atr.forms as forms
+import atr.htm as htm
+import atr.models.sql as sql
+import atr.post as post
+import atr.shared as shared
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/distributions/list/<project>/<version>")
+async def list_get(session: web.Committer, project: str, version: str) -> str:
+    async with db.session() as data:
+        distributions = await data.distribution(
+            release_name=sql.release_name(project, version),
+        ).all()
+
+    block = htm.Block()
+
+    release = await shared.distribution.release_validated(project, version, 
staging=None)
+    staging = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+    shared.distribution.html_nav_phase(block, project, version, staging)
+
+    record_a_distribution = htpy.a(
+        ".btn.btn-primary",
+        href=util.as_url(
+            stage if staging else record,
+            project=project,
+            version=version,
+        ),
+    )["Record a distribution"]
+
+    # Distribution list for project-version
+    block.h1["Distribution list for ", htpy.em[f"{project}-{version}"]]
+    if not distributions:
+        block.p["No distributions found."]
+        block.p[record_a_distribution]
+        return await template.blank(
+            "Distribution list",
+            content=block.collect(),
+        )
+    block.p["Here are all of the distributions recorded for this release."]
+    block.p[record_a_distribution]
+    # Table of contents
+    ul_toc = htm.Block(htpy.ul)
+    for dist in distributions:
+        a = htpy.a(href=f"#distribution-{dist.identifier}")[dist.title]
+        ul_toc.li[a]
+    block.append(ul_toc)
+
+    ## Distributions
+    block.h2["Distributions"]
+    for dist in distributions:
+        delete_form = await shared.distribution.DeleteForm.create_form(
+            data={
+                "release_name": dist.release_name,
+                "platform": dist.platform.name,
+                "owner_namespace": dist.owner_namespace,
+                "package": dist.package,
+                "version": dist.version,
+            }
+        )
+
+        ### Platform package version
+        block.h3(
+            # Cannot use "#id" here, because the ID contains "."
+            # If an ID contains ".", htpy parses that as a class
+            id=f"distribution-{dist.identifier}"
+        )[dist.title]
+        tbody = htpy.tbody[
+            shared.distribution.html_tr("Release name", dist.release_name),
+            shared.distribution.html_tr("Platform", dist.platform.value.name),
+            shared.distribution.html_tr("Owner or Namespace", 
dist.owner_namespace or "-"),
+            shared.distribution.html_tr("Package", dist.package),
+            shared.distribution.html_tr("Version", dist.version),
+            shared.distribution.html_tr("Staging", "Yes" if dist.staging else 
"No"),
+            shared.distribution.html_tr("Upload date", str(dist.upload_date)),
+            shared.distribution.html_tr_a("API URL", dist.api_url),
+            shared.distribution.html_tr_a("Web URL", dist.web_url),
+        ]
+        block.table(".table.table-striped.table-bordered")[tbody]
+        form_action = util.as_url(post.distribution.delete, project=project, 
version=version)
+        delete_form_element = forms.render_simple(
+            delete_form,
+            action=form_action,
+            submit_classes="btn-danger",
+        )
+        block.append(htpy.div(".mb-3")[delete_form_element])
+
+    title = f"Distribution list for {project} {version}"
+    return await template.blank(title, content=block.collect())
+
+
[email protected]("/distribution/record/<project>/<version>")
+async def record(session: web.Committer, project: str, version: str) -> str:
+    form = await 
shared.distribution.DistributeForm.create_form(data={"package": project, 
"version": version})
+    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
+    return await shared.distribution.record_form_page(fpv)
+
+
[email protected]("/distribution/stage/<project>/<version>")
+async def stage(session: web.Committer, project: str, version: str) -> str:
+    form = await 
shared.distribution.DistributeForm.create_form(data={"package": project, 
"version": version})
+    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
+    return await shared.distribution.record_form_page(fpv, staging=True)
diff --git a/atr/routes/vote.py b/atr/get/vote.py
similarity index 59%
rename from atr/routes/vote.py
rename to atr/get/vote.py
index 29b9fd1..e5eeadc 100644
--- a/atr/routes/vote.py
+++ b/atr/get/vote.py
@@ -16,35 +16,25 @@
 # under the License.
 
 import asfquart.base as base
-import quart
 import werkzeug.wrappers.response as response
 
+import atr.blueprints.get as get
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.log as log
 import atr.models.results as results
 import atr.models.sql as sql
-import atr.route as route
-import atr.routes.compose as compose
 import atr.routes.mapping as mapping
+import atr.shared as shared
 import atr.storage as storage
 import atr.user as user
 import atr.util as util
+import atr.web as web
 
 
-class CastVoteForm(forms.Typed):
-    """Form for casting a vote."""
-
-    vote_value = forms.radio("Your vote")
-    vote_comment = forms.textarea("Comment (optional)", optional=True)
-    submit = forms.submit("Submit vote")
-
-
[email protected]("/vote/<project_name>/<version_name>")
-async def selected(
-    session: route.CommitterSession | None, project_name: str, version_name: 
str
-) -> response.Response | str:
[email protected]("/vote/<project_name>/<version_name>")
+async def selected(session: web.Committer | None, project_name: str, 
version_name: str) -> response.Response | str:
     """Show the contents of the release candidate draft."""
     async with db.session() as data:
         release = await data.release(
@@ -102,7 +92,7 @@ async def selected(
 
     form = None
     if can_vote:
-        form = await CastVoteForm.create_form()
+        form = await shared.vote.CastVoteForm.create_form()
         async with storage.write() as write:
             try:
                 if release.committee.is_podling:
@@ -121,7 +111,7 @@ async def selected(
             ],
         )
 
-    return await compose.check(
+    return await shared.check(
         session,
         release,
         task_mid=task_mid,
@@ -132,61 +122,3 @@ async def selected(
         can_vote=can_vote,
         can_resolve=can_resolve,
     )
-
-
[email protected]("/vote/<project_name>/<version_name>", methods=["POST"])
-async def selected_post(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
-    """Handle submission of a vote."""
-    await session.check_access(project_name)
-
-    # Ensure the release exists and is in the correct phase
-    release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE)
-
-    if release.committee is None:
-        raise ValueError("Release has no committee")
-
-    # Set up form choices
-    async with storage.write() as write:
-        try:
-            if release.committee.is_podling:
-                _wacm = write.as_committee_member("incubator")
-            else:
-                _wacm = write.as_committee_member(release.committee.name)
-            potency = "Binding"
-        except storage.AccessError:
-            # Participant, due to session.check_access above
-            potency = "Non-binding"
-
-    form = await CastVoteForm.create_form(data=await quart.request.form)
-    forms.choices(
-        form.vote_value,
-        choices=[
-            ("+1", f"+1 ({potency})"),
-            ("0", "0"),
-            ("-1", f"-1 ({potency})"),
-        ],
-    )
-
-    if await form.validate_on_submit():
-        vote = str(form.vote_value.data)
-        comment = str(form.vote_comment.data)
-        async with 
storage.write_as_committee_participant(release.committee.name) as wacm:
-            email_recipient, error_message = await 
wacm.vote.send_user_vote(release, vote, comment, session.fullname)
-        if error_message:
-            return await session.redirect(
-                selected, project_name=project_name, 
version_name=version_name, error=error_message
-            )
-
-        success_message = f"Sending your vote to {email_recipient}."
-        return await session.redirect(
-            selected, project_name=project_name, version_name=version_name, 
success=success_message
-        )
-    else:
-        error_message = "Invalid vote submission"
-        if form.errors:
-            error_details = "; ".join([f"{field}: {', '.join(errs)}" for 
field, errs in form.errors.items()])
-            error_message = f"{error_message}: {error_details}"
-
-        return await session.redirect(
-            selected, project_name=project_name, version_name=version_name, 
error=error_message
-        )
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index e9131ab..768113b 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -19,9 +19,11 @@ from typing import Final, Literal
 
 import atr.post.announce as announce
 import atr.post.candidate as candidate
+import atr.post.distribution as distribution
+import atr.post.vote as vote
 
 from .example_test import respond as example_test
 
 ROUTES_MODULE: Final[Literal[True]] = True
 
-__all__ = ["announce", "candidate", "example_test"]
+__all__ = ["announce", "candidate", "distribution", "example_test", "vote"]
diff --git a/atr/post/distribution.py b/atr/post/distribution.py
new file mode 100644
index 0000000..dbf1744
--- /dev/null
+++ b/atr/post/distribution.py
@@ -0,0 +1,95 @@
+# 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.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import quart
+
+import atr.blueprints.post as post
+import atr.db as db
+import atr.get as get
+import atr.models.distribution as distribution
+import atr.shared as shared
+import atr.storage as storage
+import atr.web as web
+
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
+
+
[email protected]("/distribution/delete/<project>/<version>")
+async def delete(session: web.Committer, project: str, version: str) -> 
response.Response:
+    form = await shared.distribution.DeleteForm.create_form(data=await 
quart.request.form)
+    dd = distribution.DeleteData.model_validate(form.data)
+
+    # Validate the submitted data, and obtain the committee for its name
+    async with db.session() as data:
+        release = await 
data.release(name=dd.release_name).demand(RuntimeError(f"Release 
{dd.release_name} not found"))
+    committee = release.committee
+    if committee is None:
+        raise RuntimeError(f"Release {dd.release_name} has no committee")
+
+    # Delete the distribution
+    async with 
storage.write_as_committee_member(committee_name=committee.name) as wacm:
+        await wacm.distributions.delete_distribution(
+            release_name=dd.release_name,
+            platform=dd.platform,
+            owner_namespace=dd.owner_namespace,
+            package=dd.package,
+            version=dd.version,
+        )
+    return await session.redirect(
+        get.distribution.list_get,
+        project=project,
+        version=version,
+        success="Distribution deleted",
+    )
+
+
[email protected]("/distribution/record/<project>/<version>")
+async def record_post(session: web.Committer, project: str, version: str) -> 
str:
+    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
+    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
+    if await form.validate():
+        return await shared.distribution.record_form_process_page(fpv)
+    match len(form.errors):
+        case 0:
+            # Should not happen
+            await quart.flash("Ambiguous submission errors", 
category="warning")
+        case 1:
+            await quart.flash("There was 1 submission error", category="error")
+        case _ as n:
+            await quart.flash(f"There were {n} submission errors", 
category="error")
+    return await shared.distribution.record_form_page(fpv)
+
+
[email protected]("/distribution/stage/<project>/<version>")
+async def stage_post(session: web.Committer, project: str, version: str) -> 
str:
+    form = await shared.distribution.DistributeForm.create_form(data=await 
quart.request.form)
+    fpv = shared.distribution.FormProjectVersion(form=form, project=project, 
version=version)
+    if await form.validate():
+        return await shared.distribution.record_form_process_page(fpv, 
staging=True)
+    match len(form.errors):
+        case 0:
+            await quart.flash("Ambiguous submission errors", 
category="warning")
+        case 1:
+            await quart.flash("There was 1 submission error", category="error")
+        case _ as n:
+            await quart.flash(f"There were {n} submission errors", 
category="error")
+    return await shared.distribution.record_form_page(fpv, staging=True)
diff --git a/atr/post/vote.py b/atr/post/vote.py
new file mode 100644
index 0000000..a03da6d
--- /dev/null
+++ b/atr/post/vote.py
@@ -0,0 +1,85 @@
+# 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.forms as forms
+import atr.get.vote as get_vote
+import atr.models.sql as sql
+import atr.shared.vote as shared_vote
+import atr.storage as storage
+import atr.web as web
+
+
[email protected]("/vote/<project_name>/<version_name>")
+async def selected_post(session: web.Committer, project_name: str, 
version_name: str) -> response.Response:
+    """Handle submission of a vote."""
+    await session.check_access(project_name)
+
+    # Ensure the release exists and is in the correct phase
+    release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_CANDIDATE)
+
+    if release.committee is None:
+        raise ValueError("Release has no committee")
+
+    # Set up form choices
+    async with storage.write() as write:
+        try:
+            if release.committee.is_podling:
+                _wacm = write.as_committee_member("incubator")
+            else:
+                _wacm = write.as_committee_member(release.committee.name)
+            potency = "Binding"
+        except storage.AccessError:
+            # Participant, due to session.check_access above
+            potency = "Non-binding"
+
+    form = await shared_vote.CastVoteForm.create_form(data=await 
quart.request.form)
+    forms.choices(
+        form.vote_value,
+        choices=[
+            ("+1", f"+1 ({potency})"),
+            ("0", "0"),
+            ("-1", f"-1 ({potency})"),
+        ],
+    )
+
+    if await form.validate_on_submit():
+        vote = str(form.vote_value.data)
+        comment = str(form.vote_comment.data)
+        async with 
storage.write_as_committee_participant(release.committee.name) as wacm:
+            email_recipient, error_message = await 
wacm.vote.send_user_vote(release, vote, comment, session.fullname)
+        if error_message:
+            return await session.redirect(
+                get_vote.selected, project_name=project_name, 
version_name=version_name, error=error_message
+            )
+
+        success_message = f"Sending your vote to {email_recipient}."
+        return await session.redirect(
+            get_vote.selected, project_name=project_name, 
version_name=version_name, success=success_message
+        )
+    else:
+        error_message = "Invalid vote submission"
+        if form.errors:
+            error_details = "; ".join([f"{field}: {', '.join(errs)}" for 
field, errs in form.errors.items()])
+            error_message = f"{error_message}: {error_details}"
+
+        return await session.redirect(
+            get_vote.selected, project_name=project_name, 
version_name=version_name, error=error_message
+        )
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 5e15b9b..a0b4998 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -15,8 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import atr.routes.compose as compose
-import atr.routes.distribution as distribution
 import atr.routes.docs as docs
 import atr.routes.download as download
 import atr.routes.draft as draft
@@ -38,12 +36,9 @@ import atr.routes.start as start
 import atr.routes.tokens as tokens
 import atr.routes.upload as upload
 import atr.routes.user as user
-import atr.routes.vote as vote
 import atr.routes.voting as voting
 
 __all__ = [
-    "compose",
-    "distribution",
     "docs",
     "download",
     "draft",
@@ -65,7 +60,6 @@ __all__ = [
     "tokens",
     "upload",
     "user",
-    "vote",
     "voting",
 ]
 
diff --git a/atr/routes/compose.py b/atr/routes/compose.py
deleted file mode 100644
index 3fc4d77..0000000
--- a/atr/routes/compose.py
+++ /dev/null
@@ -1,162 +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.
-
-from typing import TYPE_CHECKING
-
-import asfquart.base as base
-import werkzeug.wrappers.response as response
-import wtforms
-
-import atr.db as db
-import atr.db.interaction as interaction
-import atr.forms as forms
-import atr.models.results as results
-import atr.models.sql as sql
-import atr.route as route
-import atr.routes.draft as draft
-import atr.routes.mapping as mapping
-import atr.storage as storage
-import atr.template as template
-import atr.util as util
-
-if TYPE_CHECKING:
-    from collections.abc import Sequence
-
-
-async def check(
-    session: route.CommitterSession | None,
-    release: sql.Release,
-    task_mid: str | None = None,
-    form: wtforms.Form | None = None,
-    hidden_form: wtforms.Form | None = None,
-    archive_url: str | None = None,
-    vote_task: sql.Task | None = None,
-    can_vote: bool = False,
-    can_resolve: bool = False,
-) -> response.Response | str:
-    base_path = util.release_directory(release)
-
-    # TODO: This takes 180ms for providers
-    # We could cache it
-    paths = [path async for path in util.paths_recursive(base_path)]
-    paths.sort()
-
-    async with storage.read(session) as read:
-        ragp = read.as_general_public()
-        info = await ragp.releases.path_info(release, paths)
-
-    user_ssh_keys: Sequence[sql.SSHKey] = []
-    asf_id: str | None = None
-    server_domain: str | None = None
-    server_host: str | None = None
-
-    if session is not None:
-        asf_id = session.uid
-        server_domain = session.app_host.split(":", 1)[0]
-        server_host = session.app_host
-        async with db.session() as data:
-            user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
-
-    # Get the number of ongoing tasks for the current revision
-    ongoing_tasks_count = 0
-    match await interaction.latest_info(release.project.name, release.version):
-        case (revision_number, revision_editor, revision_timestamp):
-            ongoing_tasks_count = await interaction.tasks_ongoing(
-                release.project.name,
-                release.version,
-                revision_number,  # type: ignore[arg-type]
-            )
-        case None:
-            revision_number = None  # type: ignore[assignment]
-            revision_editor = None  # type: ignore[assignment]
-            revision_timestamp = None  # type: ignore[assignment]
-
-    delete_draft_form = await draft.DeleteForm.create_form(
-        data={"release_name": release.name, "project_name": 
release.project.name, "version_name": release.version}
-    )
-    delete_file_form = await draft.DeleteFileForm.create_form()
-    empty_form = await forms.Empty.create_form()
-    vote_task_warnings = _warnings_from_vote_result(vote_task)
-    has_files = await util.has_files(release)
-
-    has_any_errors = any(info.errors.get(path, []) for path in paths) if info 
else False
-    strict_checking = release.project.policy_strict_checking
-    strict_checking_errors = strict_checking and has_any_errors
-
-    return await template.render(
-        "check-selected.html",
-        project_name=release.project.name,
-        version_name=release.version,
-        release=release,
-        paths=paths,
-        info=info,
-        revision_editor=revision_editor,
-        revision_time=revision_timestamp,
-        revision_number=revision_number,
-        ongoing_tasks_count=ongoing_tasks_count,
-        delete_form=delete_draft_form,
-        delete_file_form=delete_file_form,
-        asf_id=asf_id,
-        server_domain=server_domain,
-        server_host=server_host,
-        user_ssh_keys=user_ssh_keys,
-        format_datetime=util.format_datetime,
-        models=sql,
-        task_mid=task_mid,
-        form=form,
-        vote_task=vote_task,
-        archive_url=archive_url,
-        vote_task_warnings=vote_task_warnings,
-        empty_form=empty_form,
-        hidden_form=hidden_form,
-        has_files=has_files,
-        strict_checking_errors=strict_checking_errors,
-        can_vote=can_vote,
-        can_resolve=can_resolve,
-    )
-
-
[email protected]("/compose/<project_name>/<version_name>")
-async def selected(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
-    """Show the contents of the release candidate draft."""
-    await session.check_access(project_name)
-
-    async with db.session() as data:
-        release = await data.release(
-            project_name=project_name,
-            version=version_name,
-            _committee=True,
-            _project_release_policy=True,
-        ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
-    if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
-        return await mapping.release_as_redirect(session, release)
-    return await check(session, release)
-
-
-def _warnings_from_vote_result(vote_task: sql.Task | None) -> list[str]:
-    # TODO: Replace this with a schema.Strict model
-    # But we'd still need to do some of this parsing and validation
-    # We should probably rethink how to send data through tasks
-
-    if not vote_task or (not vote_task.result):
-        return ["No vote task result found."]
-
-    vote_task_result = vote_task.result
-    if not isinstance(vote_task_result, results.VoteInitiate):
-        return ["Vote task result is not a results.VoteInitiate instance."]
-
-    return vote_task_result.mail_send_warnings
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 265156d..a6a944f 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -30,10 +30,10 @@ import quart
 
 import atr.construct as construct
 import atr.forms as forms
+import atr.get.compose as compose
 import atr.log as log
 import atr.models.sql as sql
 import atr.route as route
-import atr.routes.compose as compose
 import atr.routes.root as root
 import atr.routes.upload as upload
 import atr.storage as storage
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 33a200c..738a6b0 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -31,10 +31,10 @@ import wtforms
 
 import atr.db as db
 import atr.forms as forms
+import atr.get.compose as compose
 import atr.log as log
 import atr.models.sql as sql
 import atr.route as route
-import atr.routes.compose as compose
 import atr.shared as shared
 import atr.storage as storage
 import atr.storage.outcome as outcome
diff --git a/atr/routes/mapping.py b/atr/routes/mapping.py
index c26ddcf..5adf040 100644
--- a/atr/routes/mapping.py
+++ b/atr/routes/mapping.py
@@ -19,16 +19,19 @@ from collections.abc import Callable
 
 import werkzeug.wrappers.response as response
 
+import atr.get as get
 import atr.models.sql as sql
 import atr.route as route
-import atr.routes.compose as compose
 import atr.routes.finish as finish
 import atr.routes.release as routes_release
-import atr.routes.vote as vote
 import atr.util as util
+import atr.web as web
 
 
-async def release_as_redirect(session: route.CommitterSession, release: 
sql.Release) -> response.Response:
+async def release_as_redirect(
+    session: route.CommitterSession | web.Committer,
+    release: sql.Release,
+) -> response.Response:
     route = release_as_route(release)
     if route is routes_release.finished:
         return await session.redirect(route, project_name=release.project.name)
@@ -38,9 +41,9 @@ async def release_as_redirect(session: 
route.CommitterSession, release: sql.Rele
 def release_as_route(release: sql.Release) -> Callable:
     match release.phase:
         case sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
-            return compose.selected
+            return get.compose.selected
         case sql.ReleasePhase.RELEASE_CANDIDATE:
-            return vote.selected
+            return get.vote.selected
         case sql.ReleasePhase.RELEASE_PREVIEW:
             return finish.selected
         case sql.ReleasePhase.RELEASE:
diff --git a/atr/routes/resolve.py b/atr/routes/resolve.py
index 9a255c1..023926b 100644
--- a/atr/routes/resolve.py
+++ b/atr/routes/resolve.py
@@ -20,11 +20,11 @@ import quart
 import werkzeug.wrappers.response as response
 
 import atr.forms as forms
+import atr.get.compose as compose
+import atr.get.vote as vote
 import atr.models.sql as sql
 import atr.route as route
-import atr.routes.compose as compose
 import atr.routes.finish as finish
-import atr.routes.vote as vote
 import atr.storage as storage
 import atr.tabulate as tabulate
 import atr.template as template
diff --git a/atr/routes/start.py b/atr/routes/start.py
index 99ff31a..acae711 100644
--- a/atr/routes/start.py
+++ b/atr/routes/start.py
@@ -23,9 +23,9 @@ import werkzeug.wrappers.response as response
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
+import atr.get.compose as compose
 import atr.models.sql as sql
 import atr.route as route
-import atr.routes.compose as compose
 import atr.storage as storage
 import atr.template as template
 
diff --git a/atr/routes/upload.py b/atr/routes/upload.py
index 65785e5..e8fa43a 100644
--- a/atr/routes/upload.py
+++ b/atr/routes/upload.py
@@ -23,9 +23,9 @@ import wtforms
 
 import atr.db as db
 import atr.forms as forms
+import atr.get.compose as compose
 import atr.log as log
 import atr.route as route
-import atr.routes.compose as compose
 import atr.storage as storage
 import atr.template as template
 
diff --git a/atr/routes/voting.py b/atr/routes/voting.py
index 5a30456..6c18027 100644
--- a/atr/routes/voting.py
+++ b/atr/routes/voting.py
@@ -26,12 +26,12 @@ import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
+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.routes.compose as compose
 import atr.routes.root as root
-import atr.routes.vote as vote
 import atr.storage as storage
 import atr.template as template
 import atr.user as user
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index 3604143..16dd241 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -15,9 +15,28 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Final
+from typing import TYPE_CHECKING, Final
 
+import werkzeug.wrappers.response as response
+import wtforms
+
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.forms as forms
+import atr.models.results as results
+import atr.models.sql as sql
+import atr.routes.draft as draft
 import atr.shared.announce as announce
+import atr.shared.distribution as distribution
+import atr.shared.vote as vote
+import atr.storage as storage
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+
 
 # |         1 | RSA (Encrypt or Sign) [HAC]                        |
 # |         2 | RSA Encrypt-Only [HAC]                             |
@@ -45,6 +64,118 @@ algorithms: Final[dict[int, str]] = {
 }
 
 
+async def check(
+    session: web.Committer | None,
+    release: sql.Release,
+    task_mid: str | None = None,
+    form: wtforms.Form | None = None,
+    hidden_form: wtforms.Form | None = None,
+    archive_url: str | None = None,
+    vote_task: sql.Task | None = None,
+    can_vote: bool = False,
+    can_resolve: bool = False,
+) -> response.Response | str:
+    base_path = util.release_directory(release)
+
+    # TODO: This takes 180ms for providers
+    # We could cache it
+    paths = [path async for path in util.paths_recursive(base_path)]
+    paths.sort()
+
+    async with storage.read(session) as read:
+        ragp = read.as_general_public()
+        info = await ragp.releases.path_info(release, paths)
+
+    user_ssh_keys: Sequence[sql.SSHKey] = []
+    asf_id: str | None = None
+    server_domain: str | None = None
+    server_host: str | None = None
+
+    if session is not None:
+        asf_id = session.uid
+        server_domain = session.app_host.split(":", 1)[0]
+        server_host = session.app_host
+        async with db.session() as data:
+            user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
+
+    # Get the number of ongoing tasks for the current revision
+    ongoing_tasks_count = 0
+    match await interaction.latest_info(release.project.name, release.version):
+        case (revision_number, revision_editor, revision_timestamp):
+            ongoing_tasks_count = await interaction.tasks_ongoing(
+                release.project.name,
+                release.version,
+                revision_number,  # type: ignore[arg-type]
+            )
+        case None:
+            revision_number = None  # type: ignore[assignment]
+            revision_editor = None  # type: ignore[assignment]
+            revision_timestamp = None  # type: ignore[assignment]
+
+    delete_draft_form = await draft.DeleteForm.create_form(
+        data={"release_name": release.name, "project_name": 
release.project.name, "version_name": release.version}
+    )
+    delete_file_form = await draft.DeleteFileForm.create_form()
+    empty_form = await forms.Empty.create_form()
+    vote_task_warnings = _warnings_from_vote_result(vote_task)
+    has_files = await util.has_files(release)
+
+    has_any_errors = any(info.errors.get(path, []) for path in paths) if info 
else False
+    strict_checking = release.project.policy_strict_checking
+    strict_checking_errors = strict_checking and has_any_errors
+
+    return await template.render(
+        "check-selected.html",
+        project_name=release.project.name,
+        version_name=release.version,
+        release=release,
+        paths=paths,
+        info=info,
+        revision_editor=revision_editor,
+        revision_time=revision_timestamp,
+        revision_number=revision_number,
+        ongoing_tasks_count=ongoing_tasks_count,
+        delete_form=delete_draft_form,
+        delete_file_form=delete_file_form,
+        asf_id=asf_id,
+        server_domain=server_domain,
+        server_host=server_host,
+        user_ssh_keys=user_ssh_keys,
+        format_datetime=util.format_datetime,
+        models=sql,
+        task_mid=task_mid,
+        form=form,
+        vote_task=vote_task,
+        archive_url=archive_url,
+        vote_task_warnings=vote_task_warnings,
+        empty_form=empty_form,
+        hidden_form=hidden_form,
+        has_files=has_files,
+        strict_checking_errors=strict_checking_errors,
+        can_vote=can_vote,
+        can_resolve=can_resolve,
+    )
+
+
+def _warnings_from_vote_result(vote_task: sql.Task | None) -> list[str]:
+    # TODO: Replace this with a schema.Strict model
+    # But we'd still need to do some of this parsing and validation
+    # We should probably rethink how to send data through tasks
+
+    if not vote_task or (not vote_task.result):
+        return ["No vote task result found."]
+
+    vote_task_result = vote_task.result
+    if not isinstance(vote_task_result, results.VoteInitiate):
+        return ["Vote task result is not a results.VoteInitiate instance."]
+
+    return vote_task_result.mail_send_warnings
+
+
 __all__ = [
+    "algorithms",
     "announce",
+    "check",
+    "distribution",
+    "vote",
 ]
diff --git a/atr/routes/distribution.py b/atr/shared/distribution.py
similarity index 50%
rename from atr/routes/distribution.py
rename to atr/shared/distribution.py
index 23508b8..4266e06 100644
--- a/atr/routes/distribution.py
+++ b/atr/shared/distribution.py
@@ -19,26 +19,22 @@ from __future__ import annotations
 
 import dataclasses
 import json
-from typing import TYPE_CHECKING, Literal
+from typing import Literal
 
 import htpy
 import quart
 
 import atr.db as db
 import atr.forms as forms
+import atr.get as get
 import atr.htm as htm
 import atr.models.distribution as distribution
 import atr.models.sql as sql
-import atr.route as route
-import atr.routes.compose as compose
 import atr.routes.finish as finish
 import atr.storage as storage
 import atr.template as template
 import atr.util as util
 
-if TYPE_CHECKING:
-    import werkzeug.wrappers.response as response
-
 type Phase = Literal["COMPOSE", "VOTE", "FINISH"]
 
 
@@ -93,167 +89,8 @@ class FormProjectVersion:
     version: str
 
 
[email protected]("/distribution/delete/<project>/<version>", methods=["POST"])
-async def delete(session: route.CommitterSession, project: str, version: str) 
-> response.Response:
-    form = await DeleteForm.create_form(data=await quart.request.form)
-    dd = distribution.DeleteData.model_validate(form.data)
-
-    # Validate the submitted data, and obtain the committee for its name
-    async with db.session() as data:
-        release = await 
data.release(name=dd.release_name).demand(RuntimeError(f"Release 
{dd.release_name} not found"))
-    committee = release.committee
-    if committee is None:
-        raise RuntimeError(f"Release {dd.release_name} has no committee")
-
-    # Delete the distribution
-    async with 
storage.write_as_committee_member(committee_name=committee.name) as wacm:
-        await wacm.distributions.delete_distribution(
-            release_name=dd.release_name,
-            platform=dd.platform,
-            owner_namespace=dd.owner_namespace,
-            package=dd.package,
-            version=dd.version,
-        )
-    return await session.redirect(
-        list_get,
-        project=project,
-        version=version,
-        success="Distribution deleted",
-    )
-
-
[email protected]("/distributions/list/<project>/<version>", methods=["GET"])
-async def list_get(session: route.CommitterSession, project: str, version: 
str) -> str:
-    async with db.session() as data:
-        distributions = await data.distribution(
-            release_name=sql.release_name(project, version),
-        ).all()
-
-    block = htm.Block()
-
-    release = await _release_validated(project, version, staging=None)
-    staging = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
-    _html_nav_phase(block, project, version, staging)
-
-    record_a_distribution = htpy.a(
-        ".btn.btn-primary",
-        href=util.as_url(
-            stage if staging else record,
-            project=project,
-            version=version,
-        ),
-    )["Record a distribution"]
-
-    # Distribution list for project-version
-    block.h1["Distribution list for ", htpy.em[f"{project}-{version}"]]
-    if not distributions:
-        block.p["No distributions found."]
-        block.p[record_a_distribution]
-        return await template.blank(
-            "Distribution list",
-            content=block.collect(),
-        )
-    block.p["Here are all of the distributions recorded for this release."]
-    block.p[record_a_distribution]
-    # Table of contents
-    ul_toc = htm.Block(htpy.ul)
-    for dist in distributions:
-        a = htpy.a(href=f"#distribution-{dist.identifier}")[dist.title]
-        ul_toc.li[a]
-    block.append(ul_toc)
-
-    ## Distributions
-    block.h2["Distributions"]
-    for dist in distributions:
-        delete_form = await DeleteForm.create_form(
-            data={
-                "release_name": dist.release_name,
-                "platform": dist.platform.name,
-                "owner_namespace": dist.owner_namespace,
-                "package": dist.package,
-                "version": dist.version,
-            }
-        )
-
-        ### Platform package version
-        block.h3(
-            # Cannot use "#id" here, because the ID contains "."
-            # If an ID contains ".", htpy parses that as a class
-            id=f"distribution-{dist.identifier}"
-        )[dist.title]
-        tbody = htpy.tbody[
-            _html_tr("Release name", dist.release_name),
-            _html_tr("Platform", dist.platform.value.name),
-            _html_tr("Owner or Namespace", dist.owner_namespace or "-"),
-            _html_tr("Package", dist.package),
-            _html_tr("Version", dist.version),
-            _html_tr("Staging", "Yes" if dist.staging else "No"),
-            _html_tr("Upload date", str(dist.upload_date)),
-            _html_tr_a("API URL", dist.api_url),
-            _html_tr_a("Web URL", dist.web_url),
-        ]
-        block.table(".table.table-striped.table-bordered")[tbody]
-        form_action = util.as_url(delete, project=project, version=version)
-        delete_form_element = forms.render_simple(
-            delete_form,
-            action=form_action,
-            submit_classes="btn-danger",
-        )
-        block.append(htpy.div(".mb-3")[delete_form_element])
-
-    title = f"Distribution list for {project} {version}"
-    return await template.blank(title, content=block.collect())
-
-
[email protected]("/distribution/record/<project>/<version>", methods=["GET"])
-async def record(session: route.CommitterSession, project: str, version: str) 
-> str:
-    form = await DistributeForm.create_form(data={"package": project, 
"version": version})
-    fpv = FormProjectVersion(form=form, project=project, version=version)
-    return await _record_form_page(fpv)
-
-
[email protected]("/distribution/record/<project>/<version>", methods=["POST"])
-async def record_post(session: route.CommitterSession, project: str, version: 
str) -> str:
-    form = await DistributeForm.create_form(data=await quart.request.form)
-    fpv = FormProjectVersion(form=form, project=project, version=version)
-    if await form.validate():
-        return await _record_form_process_page(fpv)
-    match len(form.errors):
-        case 0:
-            # Should not happen
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await _record_form_page(fpv)
-
-
[email protected]("/distribution/stage/<project>/<version>", methods=["GET"])
-async def stage(session: route.CommitterSession, project: str, version: str) 
-> str:
-    form = await DistributeForm.create_form(data={"package": project, 
"version": version})
-    fpv = FormProjectVersion(form=form, project=project, version=version)
-    return await _record_form_page(fpv, staging=True)
-
-
[email protected]("/distribution/stage/<project>/<version>", methods=["POST"])
-async def stage_post(session: route.CommitterSession, project: str, version: 
str) -> str:
-    form = await DistributeForm.create_form(data=await quart.request.form)
-    fpv = FormProjectVersion(form=form, project=project, version=version)
-    if await form.validate():
-        return await _record_form_process_page(fpv, staging=True)
-    match len(form.errors):
-        case 0:
-            await quart.flash("Ambiguous submission errors", 
category="warning")
-        case 1:
-            await quart.flash("There was 1 submission error", category="error")
-        case _ as n:
-            await quart.flash(f"There were {n} submission errors", 
category="error")
-    return await _record_form_page(fpv, staging=True)
-
-
 # TODO: Move this to an appropriate module
-def _html_nav(container: htm.Block, back_url: str, back_anchor: str, phase: 
Phase) -> None:
+def html_nav(container: htm.Block, back_url: str, back_anchor: str, phase: 
Phase) -> None:
     classes = ".d-flex.justify-content-between.align-items-center"
     block = htm.Block(htpy.p(classes))
     block.a(".atr-back-link", href=back_url)[f"← Back to {back_anchor}"]
@@ -284,12 +121,12 @@ def _html_nav(container: htm.Block, back_url: str, 
back_anchor: str, phase: Phas
     container.append(block)
 
 
-def _html_nav_phase(block: htm.Block, project: str, version: str, staging: 
bool) -> None:
+def html_nav_phase(block: htm.Block, project: str, version: str, staging: 
bool) -> None:
     label: Phase
-    route, label = (compose.selected, "COMPOSE")
+    route, label = (get.compose.selected, "COMPOSE")
     if not staging:
         route, label = (finish.selected, "FINISH")
-    _html_nav(
+    html_nav(
         block,
         util.as_url(
             route,
@@ -301,34 +138,34 @@ def _html_nav_phase(block: htm.Block, project: str, 
version: str, staging: bool)
     )
 
 
-def _html_submitted_values_table(block: htm.Block, dd: distribution.Data) -> 
None:
+def html_submitted_values_table(block: htm.Block, dd: distribution.Data) -> 
None:
     tbody = htpy.tbody[
-        _html_tr("Platform", dd.platform.name),
-        _html_tr("Owner or Namespace", dd.owner_namespace or "-"),
-        _html_tr("Package", dd.package),
-        _html_tr("Version", dd.version),
+        html_tr("Platform", dd.platform.name),
+        html_tr("Owner or Namespace", dd.owner_namespace or "-"),
+        html_tr("Package", dd.package),
+        html_tr("Version", dd.version),
     ]
     block.table(".table.table-striped.table-bordered")[tbody]
 
 
-def _html_tr(label: str, value: str) -> htpy.Element:
+def html_tr(label: str, value: str) -> htpy.Element:
     return htpy.tr[htpy.th[label], htpy.td[value]]
 
 
-def _html_tr_a(label: str, value: str | None) -> htpy.Element:
+def html_tr_a(label: str, value: str | None) -> htpy.Element:
     return htpy.tr[htpy.th[label], htpy.td[htpy.a(href=value)[value] if value 
else "-"]]
 
 
 # This function is used for COMPOSE (stage) and FINISH (record)
 # It's also used whenever there is an error
-async def _record_form_page(
+async def record_form_page(
     fpv: FormProjectVersion, *, extra_content: htpy.Element | None = None, 
staging: bool = False
 ) -> str:
-    await _release_validated(fpv.project, fpv.version, staging=staging)
+    await release_validated(fpv.project, fpv.version, staging=staging)
 
     # Render the explanation and form
     block = htm.Block()
-    _html_nav_phase(block, fpv.project, fpv.version, staging)
+    html_nav_phase(block, fpv.project, fpv.version, staging)
 
     # Record a manual distribution
     title_and_heading = f"Record a {'staging' if staging else 'manual'} 
distribution"
@@ -342,7 +179,9 @@ async def _record_form_page(
     ]
     block.p[
         "You can also ",
-        htpy.a(href=util.as_url(list_get, project=fpv.project, 
version=fpv.version))["view the distribution list"],
+        htpy.a(href=util.as_url(get.distribution.list_get, 
project=fpv.project, version=fpv.version))[
+            "view the distribution list"
+        ],
         ".",
     ]
     block.append(forms.render_columns(fpv.form, action=quart.request.path, 
descriptions=True))
@@ -351,9 +190,9 @@ async def _record_form_page(
     return await template.blank(title_and_heading, content=block.collect())
 
 
-async def _record_form_process_page(fpv: FormProjectVersion, /, staging: bool 
= False) -> str:
+async def record_form_process_page(fpv: FormProjectVersion, /, staging: bool = 
False) -> str:
     dd = distribution.Data.model_validate(fpv.form.data)
-    release, committee = await _release_validated_and_committee(
+    release, committee = await release_validated_and_committee(
         fpv.project,
         fpv.version,
         staging=staging,
@@ -364,7 +203,7 @@ async def _record_form_process_page(fpv: 
FormProjectVersion, /, staging: bool =
         div = htm.Block(htpy.div(".alert.alert-danger"))
         div.p[message]
         collected = div.collect()
-        return await _record_form_page(fpv, extra_content=collected, 
staging=staging)
+        return await record_form_page(fpv, extra_content=collected, 
staging=staging)
 
     async with 
storage.write_as_committee_member(committee_name=committee.name) as w:
         try:
@@ -389,18 +228,22 @@ async def _record_form_process_page(fpv: 
FormProjectVersion, /, staging: bool =
         block.p["The distribution was already recorded."]
     block.table(".table.table-striped.table-bordered")[
         htpy.tbody[
-            _html_tr("Release name", dist.release_name),
-            _html_tr("Platform", dist.platform.name),
-            _html_tr("Owner or Namespace", dist.owner_namespace or "-"),
-            _html_tr("Package", dist.package),
-            _html_tr("Version", dist.version),
-            _html_tr("Staging", "Yes" if dist.staging else "No"),
-            _html_tr("Upload date", str(dist.upload_date)),
-            _html_tr_a("API URL", dist.api_url),
-            _html_tr_a("Web URL", dist.web_url),
+            html_tr("Release name", dist.release_name),
+            html_tr("Platform", dist.platform.name),
+            html_tr("Owner or Namespace", dist.owner_namespace or "-"),
+            html_tr("Package", dist.package),
+            html_tr("Version", dist.version),
+            html_tr("Staging", "Yes" if dist.staging else "No"),
+            html_tr("Upload date", str(dist.upload_date)),
+            html_tr_a("API URL", dist.api_url),
+            html_tr_a("Web URL", dist.web_url),
         ]
     ]
-    block.p[htpy.a(href=util.as_url(list_get, project=fpv.project, 
version=fpv.version))["Back to distribution list"],]
+    block.p[
+        htpy.a(href=util.as_url(get.distribution.list_get, 
project=fpv.project, version=fpv.version))[
+            "Back to distribution list"
+        ],
+    ]
 
     if dd.details:
         ## Details
@@ -408,7 +251,7 @@ async def _record_form_process_page(fpv: 
FormProjectVersion, /, staging: bool =
 
         ### Submitted values
         block.h3["Submitted values"]
-        _html_submitted_values_table(block, dd)
+        html_submitted_values_table(block, dd)
 
         ### As JSON
         block.h3["As JSON"]
@@ -428,20 +271,20 @@ async def _record_form_process_page(fpv: 
FormProjectVersion, /, staging: bool =
     return await template.blank("Distribution submitted", 
content=block.collect())
 
 
-async def _release_validated_and_committee(
+async def release_validated_and_committee(
     project: str,
     version: str,
     *,
     staging: bool | None = None,
 ) -> tuple[sql.Release, sql.Committee]:
-    release = await _release_validated(project, version, committee=True, 
staging=staging)
+    release = await release_validated(project, version, committee=True, 
staging=staging)
     committee = release.committee
     if committee is None:
         raise RuntimeError(f"Release {project} {version} has no committee")
     return release, committee
 
 
-async def _release_validated(
+async def release_validated(
     project: str,
     version: str,
     committee: bool = False,
diff --git a/atr/post/__init__.py b/atr/shared/vote.py
similarity index 75%
copy from atr/post/__init__.py
copy to atr/shared/vote.py
index e9131ab..0738790 100644
--- a/atr/post/__init__.py
+++ b/atr/shared/vote.py
@@ -15,13 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Final, Literal
+import atr.forms as forms
 
-import atr.post.announce as announce
-import atr.post.candidate as candidate
 
-from .example_test import respond as example_test
+class CastVoteForm(forms.Typed):
+    """Form for casting a vote."""
 
-ROUTES_MODULE: Final[Literal[True]] = True
-
-__all__ = ["announce", "candidate", "example_test"]
+    vote_value = forms.radio("Your vote")
+    vote_comment = forms.textarea("Comment (optional)", optional=True)
+    submit = forms.submit("Submit vote")
diff --git a/atr/templates/check-selected-candidate-forms.html 
b/atr/templates/check-selected-candidate-forms.html
index 8cc096e..2e2b4e4 100644
--- a/atr/templates/check-selected-candidate-forms.html
+++ b/atr/templates/check-selected-candidate-forms.html
@@ -7,7 +7,7 @@
   <h2>Cast your vote</h2>
 
   <form method="post"
-        action="{{ as_url(routes.vote.selected_post, 
project_name=project_name, version_name=version_name) }}"
+        action="{{ as_url(post.vote.selected_post, project_name=project_name, 
version_name=version_name) }}"
         class="atr-canary py-4 px-5 mb-4 border rounded">
     {{ form.hidden_tag() }}
 
diff --git a/atr/templates/check-selected.html 
b/atr/templates/check-selected.html
index f06fef9..e2ac6ab 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -108,7 +108,7 @@
   {% if phase == "release_candidate_draft" %}
     <p>
       <a class="btn btn-primary"
-         href="{{ as_url(routes.distribution.stage, 
project=release.project.name, version=release.version) }}">Record a 
distribution</a>
+         href="{{ as_url(get.distribution.stage, project=release.project.name, 
version=release.version) }}">Record a distribution</a>
     </p>
     <h2 id="more-actions">More actions</h2>
     <h3 id="ignored-checks" class="mt-4">Ignored checks</h3>
diff --git a/atr/templates/draft-tools.html b/atr/templates/draft-tools.html
index 42c96f6..e474d6e 100644
--- a/atr/templates/draft-tools.html
+++ b/atr/templates/draft-tools.html
@@ -9,7 +9,7 @@
 {% endblock description %}
 
 {% block content %}
-  <a href="{{ as_url(routes.compose.selected, project_name=project_name, 
version_name=version_name) }}"
+  <a href="{{ as_url(get.compose.selected, project_name=project_name, 
version_name=version_name) }}"
      class="atr-back-link">← Back to Compose release</a>
 
   <div class="p-3 mb-4 bg-light border rounded">
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index 9082e5e..365326a 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -103,7 +103,7 @@
   <div class="alert alert-warning mb-4" role="alert">
     <p class="fw-semibold mb-1">TODO</p>
     <p class="mb-1">
-      We plan to add tools to help release managers to distribute release 
artifacts on distribution networks. Currently you must do this manually. Once 
you've distributed your release artifacts, you can <a href="{{ 
as_url(routes.distribution.record, project=release.project.name, 
version=release.version) }}">record them on the ATR</a>.
+      We plan to add tools to help release managers to distribute release 
artifacts on distribution networks. Currently you must do this manually. Once 
you've distributed your release artifacts, you can <a href="{{ 
as_url(get.distribution.record, project=release.project.name, 
version=release.version) }}">record them on the ATR</a>.
     </p>
   </div>
 
diff --git a/atr/templates/phase-view.html b/atr/templates/phase-view.html
index ac4564c..61ff04f 100644
--- a/atr/templates/phase-view.html
+++ b/atr/templates/phase-view.html
@@ -12,7 +12,7 @@
   <p class="d-flex justify-content-between align-items-center">
     {# TODO: Use mappings.py #}
     {% if phase_key == "draft" %}
-      <a href="{{ as_url(routes.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
+      <a href="{{ as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
          class="atr-back-link">← Back to Compose {{ release.short_display_name 
}}</a>
       <span>
         <strong class="atr-phase-one atr-phase-symbol">①</strong>
@@ -23,7 +23,7 @@
         <span class="atr-phase-symbol-other">③</span>
       </span>
     {% elif phase_key == "candidate" %}
-      <a href="{{ as_url(routes.vote.selected, 
project_name=release.project.name, version_name=release.version) }}"
+      <a href="{{ as_url(get.vote.selected, project_name=release.project.name, 
version_name=release.version) }}"
          class="atr-back-link">← Back to Vote for {{ 
release.short_display_name }}</a>
       <span>
         <span class="atr-phase-symbol-other">①</span>
diff --git a/atr/templates/release-select.html 
b/atr/templates/release-select.html
index 0a87864..f60b710 100644
--- a/atr/templates/release-select.html
+++ b/atr/templates/release-select.html
@@ -30,10 +30,10 @@
         {% set target_url = None %}
         {# TODO: Use mappings.py #}
         {% if phase == "release_candidate_draft" %}
-          {% set target_url = as_url(routes.compose.selected, 
project_name=project.name, version_name=release.version) %}
+          {% set target_url = as_url(get.compose.selected, 
project_name=project.name, version_name=release.version) %}
           {% set badge_class = "bg-primary" %}
         {% elif phase == "release_candidate" %}
-          {% set target_url = as_url(routes.vote.selected, 
project_name=project.name, version_name=release.version) %}
+          {% set target_url = as_url(get.vote.selected, 
project_name=project.name, version_name=release.version) %}
           {% set badge_class = "bg-warning text-dark" %}
         {% elif phase == "release_preview" %}
           {% set target_url = as_url(get.announce.selected, 
project_name=project.name, version_name=release.version) %}
diff --git a/atr/templates/report-selected-path.html 
b/atr/templates/report-selected-path.html
index aae1a66..a8db765 100644
--- a/atr/templates/report-selected-path.html
+++ b/atr/templates/report-selected-path.html
@@ -25,10 +25,10 @@
   {% set phase = release.phase.value %}
   <p class="d-flex justify-content-between align-items-center">
     {% if phase == "release_candidate_draft" %}
-      <a href="{{ as_url(routes.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
+      <a href="{{ as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
          class="atr-back-link">← Back to Compose {{ 
release.project.short_display_name }} {{ release.version }}</a>
     {% else %}
-      <a href="{{ as_url(routes.vote.selected, 
project_name=release.project.name, version_name=release.version) }}"
+      <a href="{{ as_url(get.vote.selected, project_name=release.project.name, 
version_name=release.version) }}"
          class="atr-back-link">← Back to Vote on {{ 
release.project.short_display_name }} {{ release.version }}</a>
     {% endif %}
     <span>
diff --git a/atr/templates/resolve-manual.html 
b/atr/templates/resolve-manual.html
index 42cfefd..a50be50 100644
--- a/atr/templates/resolve-manual.html
+++ b/atr/templates/resolve-manual.html
@@ -10,7 +10,7 @@
 
 {% block content %}
   <p>
-    <a href="{{ as_url(routes.vote.selected, 
project_name=release.project.name, version_name=release.version) }}"
+    <a href="{{ as_url(get.vote.selected, project_name=release.project.name, 
version_name=release.version) }}"
        class="atr-back-link">← Back to Vote for {{ release.short_display_name 
}}</a>
   </p>
 
diff --git a/atr/templates/resolve-tabulated.html 
b/atr/templates/resolve-tabulated.html
index e011cf6..dc24d14 100644
--- a/atr/templates/resolve-tabulated.html
+++ b/atr/templates/resolve-tabulated.html
@@ -10,7 +10,7 @@
 
 {% block content %}
   <p>
-    <a href="{{ as_url(routes.vote.selected, 
project_name=release.project.name, version_name=release.version) }}"
+    <a href="{{ as_url(get.vote.selected, project_name=release.project.name, 
version_name=release.version) }}"
        class="atr-back-link">← Back to Vote for {{ release.short_display_name 
}}</a>
   </p>
 
diff --git a/atr/templates/revisions-selected.html 
b/atr/templates/revisions-selected.html
index ccec16b..0dddf84 100644
--- a/atr/templates/revisions-selected.html
+++ b/atr/templates/revisions-selected.html
@@ -11,7 +11,7 @@
 {% block content %}
   <p class="d-flex justify-content-between align-items-center">
     {% if phase_key == "draft" %}
-      <a href="{{ as_url(routes.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
+      <a href="{{ as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
          class="atr-back-link">← Back to Compose {{ release.short_display_name 
}}</a>
       <span>
         <strong class="atr-phase-one atr-phase-symbol">①</strong>
diff --git a/atr/templates/upload-selected.html 
b/atr/templates/upload-selected.html
index dc5e3b8..f5ff081 100644
--- a/atr/templates/upload-selected.html
+++ b/atr/templates/upload-selected.html
@@ -10,7 +10,7 @@
 
 {% block content %}
   <p class="d-flex justify-content-between align-items-center">
-    <a href="{{ as_url(routes.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
+    <a href="{{ as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
        class="atr-back-link">← Back to Compose {{ release.short_display_name 
}}</a>
     <span>
       <strong class="atr-phase-one atr-phase-symbol">①</strong>
diff --git a/atr/templates/voting-selected-revision.html 
b/atr/templates/voting-selected-revision.html
index 05e2c78..c3b5686 100644
--- a/atr/templates/voting-selected-revision.html
+++ b/atr/templates/voting-selected-revision.html
@@ -10,7 +10,7 @@
 
 {% block content %}
   <p class="d-flex justify-content-between align-items-center">
-    <a href="{{ as_url(routes.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
+    <a href="{{ as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
        class="atr-back-link">← Back to Compose {{ release.short_display_name 
}}</a>
     <span>
       <strong class="atr-phase-one atr-phase-symbol">①</strong>
@@ -136,7 +136,7 @@
 
       <div class="mt-4 col-md-9 offset-md-3 px-1">
         {{ form.submit(class_='btn btn-primary') }}
-        <a href="{{ as_url(routes.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
+        <a href="{{ as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version) }}"
            class="btn btn-link text-secondary">Cancel</a>
       </div>
     </form>


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

Reply via email to