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 fb38bc4  Move keys, preview, and projects routes to the new layout
fb38bc4 is described below

commit fb38bc46cc1ff1b0b5128e36f6dcaf3970cd0ab9
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Oct 28 15:22:12 2025 +0000

    Move keys, preview, and projects routes to the new layout
---
 atr/admin/__init__.py                          |  23 +-
 atr/get/__init__.py                            |   6 +
 atr/get/keys.py                                | 102 +++++
 atr/get/preview.py                             |  83 ++++
 atr/get/projects.py                            |  76 ++++
 atr/post/__init__.py                           |   6 +
 atr/post/keys.py                               | 126 ++++++
 atr/{routes => post}/preview.py                |  79 +---
 atr/post/projects.py                           |  64 +++
 atr/routes/__init__.py                         |   6 -
 atr/shared/__init__.py                         |   4 +
 atr/{routes => shared}/keys.py                 | 136 +------
 atr/shared/projects.py                         | 538 +++++++++++++++++++++++++
 atr/templates/announce-selected.html           |   4 +-
 atr/templates/check-selected-path-table.html   |   2 +-
 atr/templates/check-selected-release-info.html |   2 +-
 atr/templates/committee-directory.html         |   6 +-
 atr/templates/committee-view.html              |   9 +-
 atr/templates/file-selected-path.html          |   2 +-
 atr/templates/finish-selected.html             |   2 +-
 atr/templates/includes/sidebar.html            |   6 +-
 atr/templates/index-committer.html             |   4 +-
 atr/templates/keys-add.html                    |   6 +-
 atr/templates/keys-details.html                |   2 +-
 atr/templates/keys-review.html                 |  18 +-
 atr/templates/keys-ssh-add.html                |   2 +-
 atr/templates/keys-upload.html                 |   4 +-
 atr/templates/phase-view.html                  |   2 +-
 atr/templates/project-view.html                |  12 +-
 atr/templates/projects.html                    |   4 +-
 atr/templates/release-select.html              |   2 +-
 atr/templates/upload-selected.html             |   2 +-
 atr/templates/user-ssh-keys.html               |   8 +-
 atr/templates/voting-selected-revision.html    |   2 +-
 34 files changed, 1076 insertions(+), 274 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 4c9b1fd..d0835e5 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -40,6 +40,7 @@ import atr.datasources.apache as apache
 import atr.db as db
 import atr.db.interaction as interaction
 import atr.forms as forms
+import atr.get as get
 import atr.ldap as ldap
 import atr.log as log
 import atr.models.sql as sql
@@ -291,29 +292,21 @@ async def data(session: web.Committer, model: str = 
"Committee") -> str:
 
 @admin.get("/delete-test-openpgp-keys")
 async def delete_test_openpgp_keys_get(session: web.Committer) -> 
quart.Response | response.Response:
-    return await _delete_test_openpgp_keys(session)
+    if not config.get().ALLOW_TESTS:
+        raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
+
+    delete_form = await DeleteTestKeysForm.create_form()
+    rendered_form = forms.render_simple(delete_form, action="")
+    return web.ElementResponse(rendered_form)
 
 
 @admin.post("/delete-test-openpgp-keys")
 async def delete_test_openpgp_keys_post(session: web.Committer) -> 
quart.Response | response.Response:
-    return await _delete_test_openpgp_keys(session)
-
-
-async def _delete_test_openpgp_keys(session: web.Committer) -> quart.Response 
| response.Response:
     """Delete all test user OpenPGP keys and their links."""
-    import atr.routes
-
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
 
     test_uid = "test"
-
-    if quart.request.method != "POST":
-        delete_form = await DeleteTestKeysForm.create_form()
-        rendered_form = forms.render_simple(delete_form, action="")
-        return web.ElementResponse(rendered_form)
-
-    # This is a POST request
     delete_form = await DeleteTestKeysForm.create_form()
     if not await delete_form.validate_on_submit():
         raise base.ASFQuartException("Invalid form submission. Please check 
your input and try again.", errorcode=400)
@@ -323,7 +316,7 @@ async def _delete_test_openpgp_keys(session: web.Committer) 
-> quart.Response |
         outcome = await wafc.keys.test_user_delete_all(test_uid)
         outcome.result_or_raise()
 
-    return await session.redirect(atr.routes.keys.keys)
+    return await session.redirect(get.keys.keys)
 
 
 @admin.get("/delete-committee-keys")
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 5c2c1b4..1c3a872 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -28,6 +28,9 @@ import atr.get.draft as draft
 import atr.get.file as file
 import atr.get.finish as finish
 import atr.get.ignores as ignores
+import atr.get.keys as keys
+import atr.get.preview as preview
+import atr.get.projects as projects
 import atr.get.vote as vote
 
 ROUTES_MODULE: Final[Literal[True]] = True
@@ -44,5 +47,8 @@ __all__ = [
     "file",
     "finish",
     "ignores",
+    "keys",
+    "preview",
+    "projects",
     "vote",
 ]
diff --git a/atr/get/keys.py b/atr/get/keys.py
new file mode 100644
index 0000000..d3a5a54
--- /dev/null
+++ b/atr/get/keys.py
@@ -0,0 +1,102 @@
+# 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.
+
+"""keys.py"""
+
+import datetime
+
+import asfquart as asfquart
+import quart
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.get as get
+import atr.db as db
+import atr.route as route
+import atr.shared as shared
+import atr.storage as storage
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/keys/add")
+async def add(session: web.Committer) -> str:
+    """Add a new public signing key to the user's account."""
+    return await shared.keys.add(session)
+
+
[email protected]("/keys/details/<fingerprint>")
+async def details(session: web.Committer, fingerprint: str) -> str | 
response.Response:
+    """Display details for a specific OpenPGP key."""
+    return await shared.keys.details(session, fingerprint)
+
+
[email protected]("/keys/export/<committee_name>")
+async def export(session: route.CommitterSession, committee_name: str) -> 
web.TextResponse:
+    """Export a KEYS file for a specific committee."""
+    async with storage.write() as write:
+        wafc = write.as_foundation_committer()
+        keys_file_text = await wafc.keys.keys_file_text(committee_name)
+
+    return web.TextResponse(keys_file_text)
+
+
[email protected]("/keys")
+async def keys(session: route.CommitterSession) -> str:
+    """View all keys associated with the user's account."""
+    committees_to_query = list(set(session.committees + session.projects))
+
+    delete_form = await shared.keys.DeleteKeyForm.create_form()
+    update_committee_keys_form = await 
shared.keys.UpdateCommitteeKeysForm.create_form()
+
+    async with db.session() as data:
+        user_keys = await 
data.public_signing_key(apache_uid=session.uid.lower(), _committees=True).all()
+        user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
+        user_committees_with_keys = await 
data.committee(name_in=committees_to_query, _public_signing_keys=True).all()
+    for key in user_keys:
+        key.committees.sort(key=lambda c: c.name)
+
+    status_message = quart.request.args.get("status_message")
+    status_type = quart.request.args.get("status_type")
+
+    return await template.render(
+        "keys-review.html",
+        asf_id=session.uid,
+        user_keys=user_keys,
+        user_ssh_keys=user_ssh_keys,
+        committees=user_committees_with_keys,
+        algorithms=shared.algorithms,
+        status_message=status_message,
+        status_type=status_type,
+        now=datetime.datetime.now(datetime.UTC),
+        delete_form=delete_form,
+        update_committee_keys_form=update_committee_keys_form,
+        email_from_key=util.email_from_uid,
+        committee_is_standing=util.committee_is_standing,
+    )
+
+
[email protected]("/keys/ssh/add")
+async def ssh_add(session: web.Committer) -> response.Response | str:
+    """Add a new SSH key to the user's account."""
+    return await shared.keys.ssh_add(session)
+
+
[email protected]("/keys/upload")
+async def upload(session: web.Committer) -> str:
+    """Upload a KEYS file containing multiple OpenPGP keys."""
+    return await shared.keys.upload(session)
diff --git a/atr/get/preview.py b/atr/get/preview.py
new file mode 100644
index 0000000..0a9689b
--- /dev/null
+++ b/atr/get/preview.py
@@ -0,0 +1,83 @@
+# 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.models.sql as sql
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/preview/view/<project_name>/<version_name>")
+async def view(session: web.Committer, project_name: str, version_name: str) 
-> response.Response | str:
+    """View all the files in the rsync upload directory for a release."""
+    await session.check_access(project_name)
+
+    release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_PREVIEW)
+
+    # Convert async generator to list
+    # There must be a revision on a preview
+    file_stats = [
+        stat
+        async for stat in util.content_list(
+            util.get_unfinished_dir(), project_name, version_name, 
release.unwrap_revision_number
+        )
+    ]
+    # Sort the files by FileStat.path
+    file_stats.sort(key=lambda fs: fs.path)
+
+    return await template.render(
+        # TODO: Move to somewhere appropriate
+        "phase-view.html",
+        file_stats=file_stats,
+        release=release,
+        format_datetime=util.format_datetime,
+        format_file_size=util.format_file_size,
+        format_permissions=util.format_permissions,
+        phase="release preview",
+        phase_key="preview",
+    )
+
+
[email protected]("/preview/view/<project_name>/<version_name>/<path:file_path>")
+async def view_path(
+    session: web.Committer, project_name: str, version_name: str, file_path: 
str
+) -> response.Response | str:
+    """View the content of a specific file in the release preview."""
+    await session.check_access(project_name)
+
+    release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_PREVIEW)
+    _max_view_size = 1 * 1024 * 1024
+    full_path = util.release_directory(release) / file_path
+    content_listing = await util.archive_listing(full_path)
+    content, is_text, is_truncated, error_message = await 
util.read_file_for_viewer(full_path, _max_view_size)
+    return await template.render(
+        "file-selected-path.html",
+        release=release,
+        project_name=project_name,
+        version_name=version_name,
+        file_path=file_path,
+        content=content,
+        is_text=is_text,
+        is_truncated=is_truncated,
+        error_message=error_message,
+        format_file_size=util.format_file_size,
+        phase_key="preview",
+        content_listing=content_listing,
+    )
diff --git a/atr/get/projects.py b/atr/get/projects.py
new file mode 100644
index 0000000..5a6d682
--- /dev/null
+++ b/atr/get/projects.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.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import atr.blueprints.get as get
+import atr.config as config
+import atr.db as db
+import atr.forms as forms
+import atr.models.sql as sql
+import atr.shared as shared
+import atr.template as template
+import atr.web as web
+
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
+
+
[email protected]("/project/add/<committee_name>")
+async def add_project(session: web.Committer, committee_name: str) -> 
response.Response | str:
+    return await shared.projects.add_project(session, committee_name)
+
+
[email protected]("/projects")
+async def projects(session: web.Committer | None) -> str:
+    """Main project directory page."""
+    async with db.session() as data:
+        projects = await 
data.project(_committee=True).order_by(sql.Project.full_name).all()
+        return await template.render("projects.html", projects=projects, 
empty_form=await forms.Empty.create_form())
+
+
[email protected]("/project/select")
+async def select(session: web.Committer) -> str:
+    """Select a project to work on."""
+    user_projects = []
+    if session.uid:
+        async with db.session() as data:
+            # TODO: Move this filtering logic somewhere else
+            # The ALLOW_TESTS line allows test projects to be shown
+            conf = config.get()
+            all_projects = await data.project(status=sql.ProjectStatus.ACTIVE, 
_committee=True).all()
+            user_projects = [
+                p
+                for p in all_projects
+                if p.committee
+                and (
+                    (conf.ALLOW_TESTS and (p.committee.name == "test"))
+                    or (session.uid in p.committee.committee_members)
+                    or (session.uid in p.committee.committers)
+                    or (session.uid in p.committee.release_managers)
+                )
+            ]
+            user_projects.sort(key=lambda p: p.display_name)
+
+    return await template.render("project-select.html", 
user_projects=user_projects)
+
+
[email protected]("/projects/<name>")
+async def view(session: web.Committer, name: str) -> response.Response | str:
+    return await shared.projects.view(session, name)
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index be0f6c2..5e2dfe0 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -23,6 +23,9 @@ import atr.post.distribution as distribution
 import atr.post.draft as draft
 import atr.post.finish as finish
 import atr.post.ignores as ignores
+import atr.post.keys as keys
+import atr.post.preview as preview
+import atr.post.projects as projects
 import atr.post.vote as vote
 
 ROUTES_MODULE: Final[Literal[True]] = True
@@ -34,5 +37,8 @@ __all__ = [
     "draft",
     "finish",
     "ignores",
+    "keys",
+    "preview",
+    "projects",
     "vote",
 ]
diff --git a/atr/post/keys.py b/atr/post/keys.py
new file mode 100644
index 0000000..246ef6f
--- /dev/null
+++ b/atr/post/keys.py
@@ -0,0 +1,126 @@
+# 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 as asfquart
+import quart
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.post as post
+import atr.get as get
+import atr.models.sql as sql
+import atr.shared as shared
+import atr.storage as storage
+import atr.storage.outcome as outcome
+import atr.storage.types as types
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/keys/add")
+async def add(session: web.Committer) -> str:
+    """Add a new public signing key to the user's account."""
+    return await shared.keys.add(session)
+
+
[email protected]("/keys/delete")
+async def delete(session: web.Committer) -> response.Response:
+    """Delete a public signing key or SSH key from the user's account."""
+    form = await shared.keys.DeleteKeyForm.create_form(data=await 
quart.request.form)
+
+    if not await form.validate_on_submit():
+        return await session.redirect(get.keys.keys, error="Invalid request 
for key deletion.")
+
+    fingerprint = (await quart.request.form).get("fingerprint")
+    if not fingerprint:
+        return await session.redirect(get.keys.keys, error="Missing key 
fingerprint for deletion.")
+
+    # Try to delete an SSH key first
+    # Otherwise, delete an OpenPGP key
+    # TODO: Unmerge this, or identify the key type
+    async with storage.write() as write:
+        wafc = write.as_foundation_committer()
+        try:
+            await wafc.ssh.delete_key(fingerprint)
+        except storage.AccessError:
+            pass
+        else:
+            return await session.redirect(get.keys.keys, success="SSH key 
deleted successfully")
+        oc: outcome.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
+
+    match oc:
+        case outcome.Result():
+            return await session.redirect(get.keys.keys, success="Key deleted 
successfully")
+        case outcome.Error(error):
+            return await session.redirect(get.keys.keys, error=f"Error 
deleting key: {error}")
+
+
[email protected]("/keys/details/<fingerprint>")
+async def details(session: web.Committer, fingerprint: str) -> str | 
response.Response:
+    """Display details for a specific OpenPGP key."""
+    return await shared.keys.details(session, fingerprint)
+
+
[email protected]("/keys/import/<project_name>/<version_name>")
+async def import_selected_revision(session: web.Committer, project_name: str, 
version_name: str) -> response.Response:
+    await util.validate_empty_form()
+
+    async with storage.write() as write:
+        wacm = await write.as_project_committee_member(project_name)
+        outcomes: outcome.List[types.Key] = await 
wacm.keys.import_keys_file(project_name, version_name)
+
+    message = f"Uploaded {outcomes.result_count} keys,"
+    if outcomes.error_count > 0:
+        message += f" failed to upload {outcomes.error_count} keys for 
{wacm.committee_name}"
+    return await session.redirect(
+        get.compose.selected,
+        success=message,
+        project_name=project_name,
+        version_name=version_name,
+    )
+
+
[email protected]("/keys/ssh/add")
+async def ssh_add(session: web.Committer) -> response.Response | str:
+    """Add a new SSH key to the user's account."""
+    return await shared.keys.ssh_add(session)
+
+
[email protected]("/keys/update-committee-keys/<committee_name>")
+async def update_committee_keys(session: web.Committer, committee_name: str) 
-> response.Response:
+    """Generate and save the KEYS file for a specific committee."""
+    form = await shared.keys.UpdateCommitteeKeysForm.create_form()
+    if not await form.validate_on_submit():
+        return await session.redirect(get.keys.keys, error="Invalid request to 
update KEYS file.")
+
+    async with storage.write() as write:
+        wacm = write.as_committee_member(committee_name)
+        match await wacm.keys.autogenerate_keys_file():
+            case outcome.Result():
+                await quart.flash(
+                    f'Successfully regenerated the KEYS file for the 
"{committee_name}" committee.', "success"
+                )
+            case outcome.Error():
+                await quart.flash(f"Error regenerating the KEYS file for the 
{committee_name} committee.", "error")
+
+    return await session.redirect(get.keys.keys)
+
+
[email protected]("/keys/upload")
+async def upload(session: web.Committer) -> str:
+    """Upload a KEYS file containing multiple OpenPGP keys."""
+    return await shared.keys.upload(session)
diff --git a/atr/routes/preview.py b/atr/post/preview.py
similarity index 57%
rename from atr/routes/preview.py
rename to atr/post/preview.py
index e29e130..9e55944 100644
--- a/atr/routes/preview.py
+++ b/atr/post/preview.py
@@ -15,26 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""preview.py"""
-
-import asfquart
 import quart
 import werkzeug.wrappers.response as response
 
+import atr.blueprints.post as post
 import atr.construct as construct
 import atr.forms as forms
 import atr.log as log
 import atr.models.sql as sql
-import atr.route as route
 import atr.routes.root as root
 import atr.storage as storage
-import atr.template as template
-import atr.util as util
 import atr.web as web
 
-if asfquart.APP is ...:
-    raise RuntimeError("APP is not set")
-
 
 class AnnouncePreviewForm(forms.Typed):
     """Form for validating preview request data."""
@@ -53,12 +45,13 @@ class DeleteForm(forms.Typed):
     submit = forms.submit("Delete preview")
 
 
[email protected]("/preview/announce/<project_name>/<version_name>", 
methods=["POST"])
[email protected]("/preview/announce/<project_name>/<version_name>")
 async def announce_preview(
-    session: route.CommitterSession, project_name: str, version_name: str
+    session: web.Committer, project_name: str, version_name: str
 ) -> quart.wrappers.response.Response | str:
     """Generate a preview of the announcement email body."""
 
+    # TODO: Where does this come from? A static template?
     form = await AnnouncePreviewForm.create_form(data=await quart.request.form)
     if not await form.validate_on_submit():
         error_message = "Invalid preview request"
@@ -84,9 +77,10 @@ async def announce_preview(
         return web.TextResponse(f"Error generating preview: {e!s}", status=500)
 
 
[email protected]("/preview/delete", methods=["POST"])
-async def delete(session: route.CommitterSession) -> response.Response:
[email protected]("/preview/delete")
+async def delete(session: web.Committer) -> response.Response:
     """Delete a preview and all its associated files."""
+    # TODO: Where does this come from? A static template?
     form = await DeleteForm.create_form(data=await quart.request.form)
 
     if not await form.validate_on_submit():
@@ -109,62 +103,3 @@ async def delete(session: route.CommitterSession) -> 
response.Response:
         )
 
     return await session.redirect(root.index, success="Preview deleted 
successfully")
-
-
[email protected]("/preview/view/<project_name>/<version_name>")
-async def view(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
-    """View all the files in the rsync upload directory for a release."""
-    await session.check_access(project_name)
-
-    release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_PREVIEW)
-
-    # Convert async generator to list
-    # There must be a revision on a preview
-    file_stats = [
-        stat
-        async for stat in util.content_list(
-            util.get_unfinished_dir(), project_name, version_name, 
release.unwrap_revision_number
-        )
-    ]
-    # Sort the files by FileStat.path
-    file_stats.sort(key=lambda fs: fs.path)
-
-    return await template.render(
-        # TODO: Move to somewhere appropriate
-        "phase-view.html",
-        file_stats=file_stats,
-        release=release,
-        format_datetime=util.format_datetime,
-        format_file_size=util.format_file_size,
-        format_permissions=util.format_permissions,
-        phase="release preview",
-        phase_key="preview",
-    )
-
-
[email protected]("/preview/view/<project_name>/<version_name>/<path:file_path>")
-async def view_path(
-    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
-) -> response.Response | str:
-    """View the content of a specific file in the release preview."""
-    await session.check_access(project_name)
-
-    release = await session.release(project_name, version_name, 
phase=sql.ReleasePhase.RELEASE_PREVIEW)
-    _max_view_size = 1 * 1024 * 1024
-    full_path = util.release_directory(release) / file_path
-    content_listing = await util.archive_listing(full_path)
-    content, is_text, is_truncated, error_message = await 
util.read_file_for_viewer(full_path, _max_view_size)
-    return await template.render(
-        "file-selected-path.html",
-        release=release,
-        project_name=project_name,
-        version_name=version_name,
-        file_path=file_path,
-        content=content,
-        is_text=is_text,
-        is_truncated=is_truncated,
-        error_message=error_message,
-        format_file_size=util.format_file_size,
-        phase_key="preview",
-        content_listing=content_listing,
-    )
diff --git a/atr/post/projects.py b/atr/post/projects.py
new file mode 100644
index 0000000..21626af
--- /dev/null
+++ b/atr/post/projects.py
@@ -0,0 +1,64 @@
+# 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.get as get
+import atr.shared as shared
+import atr.storage as storage
+import atr.util as util
+import atr.web as web
+
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
+
+
[email protected]("/project/add/<committee_name>")
+async def add_project(session: web.Committer, committee_name: str) -> 
response.Response | str:
+    return await shared.projects.add_project(session, committee_name)
+
+
[email protected]("/project/delete")
+async def delete(session: web.Committer) -> response.Response:
+    """Delete a project created by the user."""
+    # TODO: This is not truly empty, so make a form object for this
+    await util.validate_empty_form()
+    form_data = await quart.request.form
+    project_name = form_data.get("project_name")
+    if not project_name:
+        return await session.redirect(get.projects.projects, error="Missing 
project name for deletion.")
+
+    async with storage.write(session) as write:
+        wacm = await write.as_project_committee_member(project_name)
+        try:
+            await wacm.project.delete(project_name)
+        except storage.AccessError as e:
+            # TODO: Redirect to committees
+            return await session.redirect(get.projects.projects, error=f"Error 
deleting project: {e}")
+
+    # TODO: Redirect to committees
+    return await session.redirect(get.projects.projects, success=f"Project 
'{project_name}' deleted successfully.")
+
+
[email protected]("/projects/<name>")
+async def view(session: web.Committer, name: str) -> response.Response | str:
+    return await shared.projects.view(session, name)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index a3c6e18..9d61ba3 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -15,9 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import atr.routes.keys as keys
-import atr.routes.preview as preview
-import atr.routes.projects as projects
 import atr.routes.published as published
 import atr.routes.ref as ref
 import atr.routes.release as release
@@ -33,9 +30,6 @@ import atr.routes.user as user
 import atr.routes.voting as voting
 
 __all__ = [
-    "keys",
-    "preview",
-    "projects",
     "published",
     "ref",
     "release",
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index df2ffe0..a22b198 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -30,6 +30,8 @@ import atr.shared.distribution as distribution
 import atr.shared.draft as draft
 import atr.shared.finish as finish
 import atr.shared.ignores as ignores
+import atr.shared.keys as keys
+import atr.shared.projects as projects
 import atr.shared.vote as vote
 import atr.storage as storage
 import atr.template as template
@@ -182,5 +184,7 @@ __all__ = [
     "draft",
     "finish",
     "ignores",
+    "keys",
+    "projects",
     "vote",
 ]
diff --git a/atr/routes/keys.py b/atr/shared/keys.py
similarity index 73%
rename from atr/routes/keys.py
rename to atr/shared/keys.py
index 71e0565..b9d50b5 100644
--- a/atr/routes/keys.py
+++ b/atr/shared/keys.py
@@ -31,7 +31,7 @@ import wtforms
 
 import atr.db as db
 import atr.forms as forms
-import atr.get.compose as compose
+import atr.get as get
 import atr.log as log
 import atr.models.sql as sql
 import atr.route as route
@@ -135,8 +135,7 @@ class UploadKeyFormBase(forms.Typed):
         return True
 
 
[email protected]("/keys/add", methods=["GET", "POST"])
-async def add(session: route.CommitterSession) -> str:
+async def add(session: web.Committer) -> str:
     """Add a new public signing key to the user's account."""
     key_info = None
 
@@ -191,40 +190,7 @@ async def add(session: route.CommitterSession) -> str:
     )
 
 
[email protected]("/keys/delete", methods=["POST"])
-async def delete(session: route.CommitterSession) -> response.Response:
-    """Delete a public signing key or SSH key from the user's account."""
-    form = await DeleteKeyForm.create_form(data=await quart.request.form)
-
-    if not await form.validate_on_submit():
-        return await session.redirect(keys, error="Invalid request for key 
deletion.")
-
-    fingerprint = (await quart.request.form).get("fingerprint")
-    if not fingerprint:
-        return await session.redirect(keys, error="Missing key fingerprint for 
deletion.")
-
-    # Try to delete an SSH key first
-    # Otherwise, delete an OpenPGP key
-    # TODO: Unmerge this, or identify the key type
-    async with storage.write() as write:
-        wafc = write.as_foundation_committer()
-        try:
-            await wafc.ssh.delete_key(fingerprint)
-        except storage.AccessError:
-            pass
-        else:
-            return await session.redirect(keys, success="SSH key deleted 
successfully")
-        oc: outcome.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
-
-    match oc:
-        case outcome.Result():
-            return await session.redirect(keys, success="Key deleted 
successfully")
-        case outcome.Error(error):
-            return await session.redirect(keys, error=f"Error deleting key: 
{error}")
-
-
[email protected]("/keys/details/<fingerprint>", methods=["GET", "POST"])
-async def details(session: route.CommitterSession, fingerprint: str) -> str | 
response.Response:
+async def details(session: web.Committer, fingerprint: str) -> str | 
response.Response:
     """Display details for a specific OpenPGP key."""
     fingerprint = fingerprint.lower()
     user_committees = []
@@ -282,74 +248,7 @@ async def details(session: route.CommitterSession, 
fingerprint: str) -> str | re
     )
 
 
[email protected]("/keys/export/<committee_name>")
-async def export(session: route.CommitterSession, committee_name: str) -> 
web.TextResponse:
-    """Export a KEYS file for a specific committee."""
-    async with storage.write() as write:
-        wafc = write.as_foundation_committer()
-        keys_file_text = await wafc.keys.keys_file_text(committee_name)
-
-    return web.TextResponse(keys_file_text)
-
-
[email protected]("/keys/import/<project_name>/<version_name>", 
methods=["POST"])
-async def import_selected_revision(
-    session: route.CommitterSession, project_name: str, version_name: str
-) -> response.Response:
-    await util.validate_empty_form()
-
-    async with storage.write() as write:
-        wacm = await write.as_project_committee_member(project_name)
-        outcomes: outcome.List[types.Key] = await 
wacm.keys.import_keys_file(project_name, version_name)
-
-    message = f"Uploaded {outcomes.result_count} keys,"
-    if outcomes.error_count > 0:
-        message += f" failed to upload {outcomes.error_count} keys for 
{wacm.committee_name}"
-    return await session.redirect(
-        compose.selected,
-        success=message,
-        project_name=project_name,
-        version_name=version_name,
-    )
-
-
[email protected]("/keys")
-async def keys(session: route.CommitterSession) -> str:
-    """View all keys associated with the user's account."""
-    committees_to_query = list(set(session.committees + session.projects))
-
-    delete_form = await DeleteKeyForm.create_form()
-    update_committee_keys_form = await UpdateCommitteeKeysForm.create_form()
-
-    async with db.session() as data:
-        user_keys = await 
data.public_signing_key(apache_uid=session.uid.lower(), _committees=True).all()
-        user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
-        user_committees_with_keys = await 
data.committee(name_in=committees_to_query, _public_signing_keys=True).all()
-    for key in user_keys:
-        key.committees.sort(key=lambda c: c.name)
-
-    status_message = quart.request.args.get("status_message")
-    status_type = quart.request.args.get("status_type")
-
-    return await template.render(
-        "keys-review.html",
-        asf_id=session.uid,
-        user_keys=user_keys,
-        user_ssh_keys=user_ssh_keys,
-        committees=user_committees_with_keys,
-        algorithms=shared.algorithms,
-        status_message=status_message,
-        status_type=status_type,
-        now=datetime.datetime.now(datetime.UTC),
-        delete_form=delete_form,
-        update_committee_keys_form=update_committee_keys_form,
-        email_from_key=util.email_from_uid,
-        committee_is_standing=util.committee_is_standing,
-    )
-
-
[email protected]("/keys/ssh/add", methods=["GET", "POST"])
-async def ssh_add(session: route.CommitterSession) -> response.Response | str:
+async def ssh_add(session: web.Committer) -> response.Response | str:
     """Add a new SSH key to the user's account."""
     # TODO: Make an auth.require wrapper that gives the session automatically
     # And the form if it's a POST handler? Might be hard to type
@@ -371,7 +270,7 @@ async def ssh_add(session: route.CommitterSession) -> 
response.Response | str:
                 form.key.errors = [str(e)]
         else:
             success_message = f"SSH key added successfully: {fingerprint}"
-            return await session.redirect(keys, success=success_message)
+            return await session.redirect(get.keys.keys, 
success=success_message)
 
     return await template.render(
         "keys-ssh-add.html",
@@ -381,28 +280,7 @@ async def ssh_add(session: route.CommitterSession) -> 
response.Response | str:
     )
 
 
[email protected]("/keys/update-committee-keys/<committee_name>", 
methods=["POST"])
-async def update_committee_keys(session: route.CommitterSession, 
committee_name: str) -> response.Response:
-    """Generate and save the KEYS file for a specific committee."""
-    form = await UpdateCommitteeKeysForm.create_form()
-    if not await form.validate_on_submit():
-        return await session.redirect(keys, error="Invalid request to update 
KEYS file.")
-
-    async with storage.write() as write:
-        wacm = write.as_committee_member(committee_name)
-        match await wacm.keys.autogenerate_keys_file():
-            case outcome.Result():
-                await quart.flash(
-                    f'Successfully regenerated the KEYS file for the 
"{committee_name}" committee.', "success"
-                )
-            case outcome.Error():
-                await quart.flash(f"Error regenerating the KEYS file for the 
{committee_name} committee.", "error")
-
-    return await session.redirect(keys)
-
-
[email protected]("/keys/upload", methods=["GET", "POST"])
-async def upload(session: route.CommitterSession) -> str:
+async def upload(session: web.Committer) -> str:
     """Upload a KEYS file containing multiple OpenPGP keys."""
     async with storage.write() as write:
         participant_of_committees = await write.participant_of_committees()
@@ -505,7 +383,7 @@ async def _get_keys_text(keys_url: str, render: 
Callable[[str], Awaitable[str]])
 
 
 async def _key_and_is_owner(
-    data: db.Session, session: route.CommitterSession, fingerprint: str
+    data: db.Session, session: web.Committer, fingerprint: str
 ) -> tuple[sql.PublicSigningKey, bool]:
     key = await data.public_signing_key(fingerprint=fingerprint, 
_committees=True).get()
     if not key:
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
new file mode 100644
index 0000000..18e9017
--- /dev/null
+++ b/atr/shared/projects.py
@@ -0,0 +1,538 @@
+# 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 datetime
+import http.client
+import re
+from typing import TYPE_CHECKING, Any
+
+import asfquart.base as base
+import quart
+
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.forms as forms
+import atr.log as log
+import atr.models.policy as policy
+import atr.models.sql as sql
+import atr.registry as registry
+import atr.shared as shared
+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
+
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
+
+
+class AddForm(forms.Typed):
+    committee_name = forms.hidden()
+    display_name = forms.string("Display name")
+    label = forms.string("Label")
+    submit = forms.submit("Add project")
+
+
+class ProjectMetadataForm(forms.Typed):
+    project_name = forms.hidden()
+    category_to_add = forms.optional("New category name")
+    language_to_add = forms.optional("New language name")
+
+
+class ReleasePolicyForm(forms.Typed):
+    """
+    A Form to create or edit a ReleasePolicy.
+
+    TODO: Currently only a single mailto_address is supported.
+          see: 
https://stackoverflow.com/questions/49066046/append-entry-to-fieldlist-with-flask-wtforms-using-ajax
+    """
+
+    project_name = forms.hidden()
+
+    # Compose section
+    source_artifact_paths = forms.textarea(
+        "Source artifact paths",
+        optional=True,
+        rows=5,
+        description="Paths to source artifacts to be included in the release.",
+    )
+    binary_artifact_paths = forms.textarea(
+        "Binary artifact paths",
+        optional=True,
+        rows=5,
+        description="Paths to binary artifacts to be included in the release.",
+    )
+    github_repository_name = forms.optional(
+        "GitHub repository name",
+        description="The name of the GitHub repository to use for the release, 
excluding the apache/ prefix.",
+    )
+    github_compose_workflow_path = forms.textarea(
+        "GitHub compose workflow paths",
+        optional=True,
+        rows=5,
+        description="The full paths to the GitHub workflows to use for the 
release,"
+        " including the .github/workflows/ prefix.",
+    )
+    strict_checking = forms.boolean(
+        "Strict checking", description="If enabled, then the release cannot be 
voted upon unless all checks pass."
+    )
+
+    # Vote section
+    github_vote_workflow_path = forms.textarea(
+        "GitHub vote workflow paths",
+        optional=True,
+        rows=5,
+        description="The full paths to the GitHub workflows to use for the 
release,"
+        " including the .github/workflows/ prefix.",
+    )
+    mailto_addresses = forms.string(
+        "Email",
+        validators=[forms.REQUIRED, forms.EMAIL],
+        placeholder="E.g. [email protected]",
+        description=f"The mailing list where vote emails are sent. This is 
usually"
+        "your dev list. ATR will currently only send test announcement emails 
to"
+        f"{util.USER_TESTS_ADDRESS}.",
+    )
+    manual_vote = forms.boolean(
+        "Manual voting process",
+        description="If this is set then the vote will be completely manual 
and following policy is ignored.",
+    )
+    default_min_hours_value_at_render = forms.hidden()
+    min_hours = forms.integer(
+        "Minimum voting period",
+        validators=[util.validate_vote_duration],
+        default=72,
+        description="The minimum time to run the vote, in hours. Must be 0 or 
between 72 and 144 inclusive."
+        " If 0, then wait until 3 +1 votes and more +1 than -1.",
+    )
+    pause_for_rm = forms.boolean(
+        "Pause for RM", description="If enabled, RM can confirm manually if 
the vote has passed."
+    )
+    release_checklist = forms.textarea(
+        "Release checklist",
+        optional=True,
+        rows=10,
+        description="Markdown text describing how to test release candidates.",
+    )
+    default_start_vote_template_hash = forms.hidden()
+    start_vote_template = forms.textarea(
+        "Start vote template",
+        optional=True,
+        rows=10,
+        description="Email template for messages to start a vote on a 
release.",
+    )
+
+    # Finish section
+    default_announce_release_template_hash = forms.hidden()
+    announce_release_template = forms.textarea(
+        "Announce release template",
+        optional=True,
+        rows=10,
+        description="Email template for messages to announce a finished 
release.",
+    )
+    github_finish_workflow_path = forms.textarea(
+        "GitHub finish workflow paths",
+        optional=True,
+        rows=5,
+        description="The full paths to the GitHub workflows to use for the 
release,"
+        " including the .github/workflows/ prefix.",
+    )
+    preserve_download_files = forms.boolean(
+        "Preserve download files",
+        description="If enabled, existing download files will not be 
overwritten.",
+    )
+
+    submit_policy = forms.submit("Save")
+
+    async def validate(self, extra_validators: dict[str, Any] | None = None) 
-> bool:  # noqa: C901
+        await super().validate(extra_validators=extra_validators)
+
+        if self.manual_vote.data:
+            for field_name in (
+                "mailto_addresses",
+                "min_hours",
+                "pause_for_rm",
+                "release_checklist",
+                "start_vote_template",
+            ):
+                field = getattr(self, field_name, None)
+                if field is not None:
+                    forms.clear_errors(field)
+                self.errors.pop(field_name, None)
+
+        if self.manual_vote.data and self.strict_checking.data:
+            msg = "Manual voting process and strict checking cannot be enabled 
simultaneously."
+            forms.error(self.manual_vote, msg)
+            forms.error(self.strict_checking, msg)
+
+        github_repository_name = (self.github_repository_name.data or 
"").strip()
+        compose_raw = self.github_compose_workflow_path.data or ""
+        vote_raw = self.github_vote_workflow_path.data or ""
+        finish_raw = self.github_finish_workflow_path.data or ""
+        compose = [p.strip() for p in compose_raw.split("\n") if p.strip()]
+        vote = [p.strip() for p in vote_raw.split("\n") if p.strip()]
+        finish = [p.strip() for p in finish_raw.split("\n") if p.strip()]
+
+        any_path = bool(compose or vote or finish)
+        if any_path and (not github_repository_name):
+            forms.error(
+                self.github_repository_name,
+                "GitHub repository name is required when any workflow path is 
set.",
+            )
+
+        if github_repository_name and ("/" in github_repository_name):
+            forms.error(self.github_repository_name, "GitHub repository name 
must not contain a slash.")
+
+        if compose:
+            for p in compose:
+                if not p.startswith(".github/workflows/"):
+                    forms.error(
+                        self.github_compose_workflow_path,
+                        "GitHub workflow paths must start with 
'.github/workflows/'.",
+                    )
+                    break
+        if vote:
+            for p in vote:
+                if not p.startswith(".github/workflows/"):
+                    forms.error(
+                        self.github_vote_workflow_path,
+                        "GitHub workflow paths must start with 
'.github/workflows/'.",
+                    )
+                    break
+        if finish:
+            for p in finish:
+                if not p.startswith(".github/workflows/"):
+                    forms.error(
+                        self.github_finish_workflow_path,
+                        "GitHub workflow paths must start with 
'.github/workflows/'.",
+                    )
+                    break
+
+        return not self.errors
+
+
+async def add_project(session: web.Committer, committee_name: str) -> 
response.Response | str:
+    await session.check_access_committee(committee_name)
+
+    async with db.session() as data:
+        committee = await data.committee(name=committee_name).demand(
+            base.ASFQuartException(f"Committee {committee_name} not found", 
errorcode=404)
+        )
+
+    form = await AddForm.create_form(data={"committee_name": committee_name})
+    form.display_name.description = f"""\
+For example, "Apache {committee.display_name}" or "Apache 
{committee.display_name} Components".
+You must start with "Apache " and you must use title case.
+"""
+    form.label.description = f"""\
+For example, "{committee.name}" or "{committee.name}-components".
+You must start with your committee label, and you must use lower case.
+"""
+
+    if await form.validate_on_submit():
+        return await _project_add(form, session)
+
+    return await template.render("project-add-project.html", form=form, 
committee_name=committee.display_name)
+
+
+async def view(session: web.Committer, name: str) -> response.Response | str:
+    policy_form = None
+    metadata_form = None
+    can_edit = False
+
+    async with db.session() as data:
+        project = await data.project(
+            name=name, _committee=True, _committee_public_signing_keys=True, 
_release_policy=True
+        ).demand(http.client.HTTPException(404))
+
+    is_committee_member = project.committee and 
(user.is_committee_member(project.committee, session.uid))
+    is_privileged = user.is_admin(session.uid)
+    can_edit = is_committee_member or is_privileged
+
+    if can_edit and (quart.request.method == "POST"):
+        form_data = await quart.request.form
+        if "submit_metadata" in form_data:
+            edited_metadata, metadata_form = await _metadata_edit(session, 
project, form_data)
+            if edited_metadata is True:
+                return quart.redirect(util.as_url(view, name=project.name))
+        elif "submit_policy" in form_data:
+            policy_form = await ReleasePolicyForm.create_form(data=form_data)
+            if await policy_form.validate_on_submit():
+                policy_data = 
policy.ReleasePolicyData.model_validate(policy_form.data)
+                async with storage.write(session) as write:
+                    wacm = await 
write.as_project_committee_member(project.name)
+                    try:
+                        await wacm.policy.edit(project, policy_data)
+                    except storage.AccessError as e:
+                        return await session.redirect(view, name=project.name, 
error=f"Error editing policy: {e}")
+                    return quart.redirect(util.as_url(view, name=project.name))
+            else:
+                log.info(f"policy_form.errors: {policy_form.errors}")
+        else:
+            log.info(f"Unknown form data: {form_data}")
+
+    if metadata_form is None:
+        metadata_form = await 
ProjectMetadataForm.create_form(data={"project_name": project.name})
+    if policy_form is None:
+        policy_form = await _policy_form_create(project)
+    candidate_drafts = await interaction.candidate_drafts(project)
+    candidates = await interaction.candidates(project)
+    previews = await interaction.previews(project)
+    full_releases = await interaction.full_releases(project)
+
+    return await template.render(
+        "project-view.html",
+        project=project,
+        algorithms=shared.algorithms,
+        candidate_drafts=candidate_drafts,
+        candidates=candidates,
+        previews=previews,
+        full_releases=full_releases,
+        number_of_release_files=util.number_of_release_files,
+        now=datetime.datetime.now(datetime.UTC),
+        empty_form=await forms.Empty.create_form(),
+        policy_form=policy_form,
+        can_edit=can_edit,
+        metadata_form=metadata_form,
+        forbidden_categories=registry.FORBIDDEN_PROJECT_CATEGORIES,
+    )
+
+
+async def _metadata_category_add(
+    wacm: storage.WriteAsCommitteeMember, project: sql.Project, 
category_to_add: str
+) -> bool:
+    modified = False
+    try:
+        modified = await wacm.project.category_add(project, 
category_to_add.strip())
+    except storage.AccessError as e:
+        await quart.flash(f"Error adding category: {e}", "error")
+    if modified:
+        await quart.flash(f"Category '{category_to_add}' added.", "success")
+    else:
+        await quart.flash(f"Category '{category_to_add}' already exists.", 
"error")
+    return modified
+
+
+async def _metadata_category_remove(
+    wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value: 
str
+) -> bool:
+    modified = False
+    try:
+        modified = await wacm.project.category_remove(project, action_value)
+    except storage.AccessError as e:
+        await quart.flash(f"Error removing category: {e}", "error")
+    if modified:
+        await quart.flash(f"Category '{action_value}' removed.", "success")
+    else:
+        await quart.flash(f"Category '{action_value}' does not exist.", 
"error")
+    return modified
+
+
+async def _metadata_edit(
+    session: web.Committer, project: sql.Project, form_data: dict[str, str]
+) -> tuple[bool, ProjectMetadataForm]:
+    metadata_form = await ProjectMetadataForm.create_form(data=form_data)
+
+    validated = await metadata_form.validate_on_submit()
+    if not validated:
+        return False, metadata_form
+
+    form_data = await quart.request.form
+    action_full = form_data.get("action", "")
+    action_type = ""
+    action_value = ""
+    if ":" in action_full:
+        action_type, action_value = action_full.split(":", 1)
+    else:
+        action_type = action_full
+
+    # TODO: Add error handling
+    modified = False
+    category_to_add = metadata_form.category_to_add.data
+    language_to_add = metadata_form.language_to_add.data
+
+    async with storage.write(session) as write:
+        wacm = await write.as_project_committee_member(project.name)
+
+        if (action_type == "add_category") and category_to_add:
+            modified = await _metadata_category_add(wacm, project, 
category_to_add)
+        elif (action_type == "remove_category") and action_value:
+            modified = await _metadata_category_remove(wacm, project, 
action_value)
+        elif (action_type == "add_language") and language_to_add:
+            modified = await _metadata_language_add(wacm, project, 
language_to_add)
+        elif (action_type == "remove_language") and action_value:
+            modified = await _metadata_language_remove(wacm, project, 
action_value)
+
+    return modified, metadata_form
+
+
+async def _metadata_language_add(
+    wacm: storage.WriteAsCommitteeMember, project: sql.Project, 
language_to_add: str
+) -> bool:
+    modified = False
+    try:
+        modified = await wacm.project.language_add(project, language_to_add)
+    except storage.AccessError as e:
+        await quart.flash(f"Error adding language: {e}", "error")
+    if modified:
+        await quart.flash(f"Language '{language_to_add}' added.", "success")
+    else:
+        await quart.flash(f"Language '{language_to_add}' already exists.", 
"error")
+    return modified
+
+
+async def _metadata_language_remove(
+    wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value: 
str
+) -> bool:
+    modified = False
+    try:
+        modified = await wacm.project.language_remove(project, action_value)
+    except storage.AccessError as e:
+        await quart.flash(f"Error removing language: {e}", "error")
+    if modified:
+        await quart.flash(f"Language '{action_value}' removed.", "success")
+    else:
+        await quart.flash(f"Language '{action_value}' does not exist.", 
"error")
+    return modified
+
+
+async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
+    # TODO: Use form order for all of these fields
+    policy_form = await ReleasePolicyForm.create_form()
+    policy_form.project_name.data = project.name
+    if project.policy_mailto_addresses:
+        policy_form.mailto_addresses.data = project.policy_mailto_addresses[0]
+    else:
+        policy_form.mailto_addresses.data = f"dev@{project.name}.apache.org"
+    policy_form.min_hours.data = project.policy_min_hours
+    policy_form.manual_vote.data = project.policy_manual_vote
+    policy_form.release_checklist.data = project.policy_release_checklist
+    policy_form.start_vote_template.data = project.policy_start_vote_template
+    policy_form.announce_release_template.data = 
project.policy_announce_release_template
+    policy_form.binary_artifact_paths.data = 
"\n".join(project.policy_binary_artifact_paths)
+    policy_form.source_artifact_paths.data = 
"\n".join(project.policy_source_artifact_paths)
+    policy_form.pause_for_rm.data = project.policy_pause_for_rm
+    policy_form.strict_checking.data = project.policy_strict_checking
+    policy_form.github_repository_name.data = 
project.policy_github_repository_name
+    policy_form.github_compose_workflow_path.data = 
"\n".join(project.policy_github_compose_workflow_path)
+    policy_form.github_vote_workflow_path.data = 
"\n".join(project.policy_github_vote_workflow_path)
+    policy_form.github_finish_workflow_path.data = 
"\n".join(project.policy_github_finish_workflow_path)
+    policy_form.preserve_download_files.data = 
project.policy_preserve_download_files
+
+    # Set the hashes and value of the current defaults
+    policy_form.default_start_vote_template_hash.data = util.compute_sha3_256(
+        project.policy_start_vote_default.encode()
+    )
+    policy_form.default_announce_release_template_hash.data = 
util.compute_sha3_256(
+        project.policy_announce_release_default.encode()
+    )
+    policy_form.default_min_hours_value_at_render.data = 
str(project.policy_default_min_hours)
+    return policy_form
+
+
+async def _project_add(form: AddForm, session: web.Committer) -> 
response.Response:
+    form_values = await _project_add_validate(form)
+    if form_values is None:
+        return quart.redirect(util.as_url(add_project, 
committee_name=form.committee_name.data))
+    committee_name, display_name, label = form_values
+
+    async with storage.write(session) as write:
+        wacm = await write.as_project_committee_member(committee_name)
+        try:
+            await wacm.project.create(committee_name, display_name, label)
+        except storage.AccessError as e:
+            await quart.flash(f"Error adding project: {e}", "error")
+            return quart.redirect(util.as_url(add_project, 
committee_name=committee_name))
+
+    return quart.redirect(util.as_url(view, name=label))
+
+
+async def _project_add_validate(form: AddForm) -> tuple[str, str, str] | None:
+    committee_name = str(form.committee_name.data)
+    # Normalise spaces in the display name, then validate
+    display_name = str(form.display_name.data).strip()
+    display_name = re.sub(r"  +", " ", display_name)
+    if not await _project_add_validate_display_name(display_name):
+        return None
+    # Hidden criterion!
+    # $ sqlite3 state/atr.db 'select full_name from project;' | grep -- 
'[^A-Za-z0-9 ]'
+    # Apache .NET Ant Library
+    # Apache Oltu - Parent
+    # Apache Commons Chain (Dormant)
+    # Apache Commons Functor (Dormant)
+    # Apache Commons OGNL (Dormant)
+    # Apache Commons Proxy (Dormant)
+    # Apache Empire-db
+    # Apache mod_ftp
+    # Apache Lucene.Net
+    # Apache mod_perl
+    # Apache Xalan for C++ XSLT Processor
+    # Apache Xerces for C++ XML Parser
+    if not display_name.replace(" ", "").replace(".", "").replace("+", 
"").isalnum():
+        await quart.flash("Display name must be alphanumeric and may include 
spaces or dots or plus signs", "error")
+        return None
+
+    label = str(form.label.data).strip()
+    if not (label.startswith(committee_name + "-") or (label == 
committee_name)):
+        await quart.flash(f"Label must start with '{committee_name}-'", 
"error")
+        return None
+    if not label.islower():
+        await quart.flash("Label must be all lower case", "error")
+        return None
+    # Hidden criterion!
+    if not label.replace("-", "").isalnum():
+        await quart.flash("Label must be alphanumeric and may include 
hyphens", "error")
+        return None
+
+    return (committee_name, display_name, label)
+
+
+async def _project_add_validate_display_name(display_name: str) -> bool:
+    # We have three criteria for display names
+    must_start_apache = "The first display name word must be 'Apache'."
+    must_have_two_words = "The display name must have at least two words."
+    must_use_correct_case = "Display name words must be in PascalCase, 
camelCase, or mod_ case."
+
+    # First criterion, the first word must be "Apache"
+    display_name_words = display_name.split(" ")
+    if display_name_words[0] != "Apache":
+        await quart.flash(must_start_apache, "error")
+        return False
+
+    # Second criterion, the display name must have two or more words
+    if not display_name_words[1:]:
+        await quart.flash(must_have_two_words, "error")
+        return False
+
+    # Third criterion, the display name must use the correct case
+    allowed_irregular_words = {".NET", "C++", "Empire-db", "Lucene.NET", 
"for", "jclouds"}
+    r_pascal_case = re.compile(r"^([A-Z][0-9a-z]*)+$")
+    r_camel_case = re.compile(r"^[a-z]*([A-Z][0-9a-z]*)+$")
+    r_mod_case = re.compile(r"^mod(_[0-9a-z]+)+$")
+    for display_name_word in display_name_words[1:]:
+        if display_name_word in allowed_irregular_words:
+            continue
+        is_pascal_case = r_pascal_case.match(display_name_word)
+        is_camel_case = r_camel_case.match(display_name_word)
+        is_mod_case = r_mod_case.match(display_name_word)
+        if not (is_pascal_case or is_camel_case or is_mod_case):
+            await quart.flash(must_use_correct_case, "error")
+            return False
+    return True
diff --git a/atr/templates/announce-selected.html 
b/atr/templates/announce-selected.html
index 0627bb7..4ec4cfa 100644
--- a/atr/templates/announce-selected.html
+++ b/atr/templates/announce-selected.html
@@ -52,7 +52,7 @@
       </div>
       <!--
       <div>
-        <a title="Show files for {{ release.name }}" href="{{ 
as_url(routes.preview.view, project_name=release.project.name, 
version_name=release.version) }}" class="btn btn-sm btn-secondary">
+        <a title="Show files for {{ release.name }}" href="{{ 
as_url(get.preview.view, project_name=release.project.name, 
version_name=release.version) }}" class="btn btn-sm btn-secondary">
           <i class="bi bi-archive"></i>
           Show files
         </a>
@@ -182,7 +182,7 @@
               return;
           }
 
-          const previewUrl = "{{ as_url(routes.preview.announce_preview, 
project_name=release.project.name, version_name=release.version) }}";
+          const previewUrl = "{{ as_url(post.preview.announce_preview, 
project_name=release.project.name, version_name=release.version) }}";
           const csrfTokenInput = 
announceForm.querySelector('input[name="csrf_token"]');
 
           if (!previewUrl || !csrfTokenInput) {
diff --git a/atr/templates/check-selected-path-table.html 
b/atr/templates/check-selected-path-table.html
index b2375b7..1134bf9 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -58,7 +58,7 @@
             <div class="d-flex justify-content-end align-items-center gap-2">
               {% if path|string == "KEYS" %}
                 <form method="post"
-                      action="{{ as_url(routes.keys.import_selected_revision, 
project_name=project_name, version_name=version_name) }}"
+                      action="{{ as_url(post.keys.import_selected_revision, 
project_name=project_name, version_name=version_name) }}"
                       class="d-inline mb-0">
                   {{ empty_form.hidden_tag() }}
 
diff --git a/atr/templates/check-selected-release-info.html 
b/atr/templates/check-selected-release-info.html
index 643d441..70804e3 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -7,7 +7,7 @@
       <div class="col-md-6">
         <p>
           <strong>Project:</strong>
-          <a href="{{ as_url(routes.projects.view, name=release.project.name) 
}}">{{ release.project.display_name }}</a>
+          <a href="{{ as_url(get.projects.view, name=release.project.name) 
}}">{{ release.project.display_name }}</a>
         </p>
         <p>
           <strong>Label:</strong> {{ release.name }}
diff --git a/atr/templates/committee-directory.html 
b/atr/templates/committee-directory.html
index 73d6904..f5d2f84 100644
--- a/atr/templates/committee-directory.html
+++ b/atr/templates/committee-directory.html
@@ -122,11 +122,11 @@
               {% if committee.projects %}
                 {% for project in committee.projects|sort(attribute="name") %}
                   <div class="card mb-3 shadow-sm page-project-subcard {% if 
loop.index > max_initial_projects %}page-project-extra d-none{% endif %} {% if 
project.status.value.lower() != "active" %}page-project-inactive{% endif %}"
-                       data-project-url="{{ as_url(routes.projects.view, 
name=project.name) }}">
+                       data-project-url="{{ as_url(get.projects.view, 
name=project.name) }}">
                     <div class="card-body p-3 d-flex flex-column h-100">
                       <div class="d-flex justify-content-between 
align-items-start">
                         <p class="mb-1 me-2 fs-6">
-                          <a href="{{ as_url(routes.projects.view, 
name=project.name) }}"
+                          <a href="{{ as_url(get.projects.view, 
name=project.name) }}"
                              class="text-decoration-none stretched-link">{{ 
project.display_name }}</a>
                         </p>
                         <div>
@@ -159,7 +159,7 @@
               {% endif %}
             </div>
             {% if current_user and is_part and (not 
committee_is_standing(committee.name)) %}
-              <a href="{{ as_url(routes.projects.add_project, 
committee_name=committee.name) }}"
+              <a href="{{ as_url(get.projects.add_project, 
committee_name=committee.name) }}"
                  title="Create a project for {{ committee.display_name }}"
                  class="text-decoration-none d-block mt-4 mb-3">
                 <div class="card h-100 shadow-sm atr-cursor-pointer 
page-project-subcard">
diff --git a/atr/templates/committee-view.html 
b/atr/templates/committee-view.html
index ee08cd4..c651777 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -33,7 +33,7 @@
       <ul>
         {% for project in projects %}
           <li>
-            <a href="{{ as_url(routes.projects.view, name=project.name) }}">{{ 
project.display_name }}</a>
+            <a href="{{ as_url(get.projects.view, name=project.name) }}">{{ 
project.display_name }}</a>
           </li>
         {% endfor %}
       </ul>
@@ -48,8 +48,7 @@
       </div>
       <div class="card-body">
         <div class="mb-4">
-          <a href="{{ as_url(routes.keys.upload) }}"
-             class="btn btn-outline-primary">Upload a KEYS file</a>
+          <a href="{{ as_url(get.keys.upload) }}" class="btn 
btn-outline-primary">Upload a KEYS file</a>
         </div>
         {% if committee.public_signing_keys %}
           <div class="table-responsive mb-2">
@@ -65,7 +64,7 @@
                 {% for key in committee.public_signing_keys %}
                   <tr>
                     <td class="text-break font-monospace px-2">
-                      <a href="{{ as_url(routes.keys.details, 
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+                      <a href="{{ as_url(get.keys.details, 
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
                     </td>
                     <td class="text-break px-2">{{ 
email_from_key(key.primary_declared_uid) or 'Not specified' }}</td>
                     <td class="text-break px-2">{{ key.apache_uid or "-" 
}}</td>
@@ -78,7 +77,7 @@
             The <code>KEYS</code> file is automatically generated when you add 
or remove a key, but you can also use the form below to manually regenerate it.
           </p>
           <form method="post"
-                action="{{ as_url(routes.keys.update_committee_keys, 
committee_name=committee.name) }}"
+                action="{{ as_url(post.keys.update_committee_keys, 
committee_name=committee.name) }}"
                 class="mb-4 d-inline-block">
             {{ update_committee_keys_form.hidden_tag() }}
 
diff --git a/atr/templates/file-selected-path.html 
b/atr/templates/file-selected-path.html
index d3cee26..38d56ea 100644
--- a/atr/templates/file-selected-path.html
+++ b/atr/templates/file-selected-path.html
@@ -15,7 +15,7 @@
   {% elif phase_key == "candidate" %}
     {% set back_url = as_url(get.candidate.view, 
project_name=release.project.name, version_name=release.version) %}
   {% elif phase_key == "preview" %}
-    {% set back_url = as_url(routes.preview.view, 
project_name=release.project.name, version_name=release.version) %}
+    {% set back_url = as_url(get.preview.view, 
project_name=release.project.name, version_name=release.version) %}
   {% elif phase_key == "release" %}
     {% set back_url = as_url(routes.release.view, 
project_name=release.project.name, version_name=release.version) %}
   {% endif %}
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index 3722886..ffe4e92 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -75,7 +75,7 @@
           Download all files
         </a>
         <a title="Show files for {{ release.name }}"
-           href="{{ as_url(routes.preview.view, 
project_name=release.project.name, version_name=release.version) }}"
+           href="{{ as_url(get.preview.view, 
project_name=release.project.name, version_name=release.version) }}"
            class="btn btn-secondary me-2">
           <i class="bi bi-archive"></i>
           Show files
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index 13a95e6..210465c 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -49,7 +49,7 @@
           <!--
         <li>
           <i class="bi bi-collection"></i>
-          <a href="{{ as_url(routes.projects.projects) }}">Browse projects</a>
+          <a href="{{ as_url(get.projects.projects) }}">Browse projects</a>
         </li>
       -->
         {% endif %}
@@ -65,7 +65,7 @@
       {% if unfinished_releases %}
         {% for project_short_display_name, project_name, releases in 
unfinished_releases %}
           <h3>
-            <a href="{{ as_url(routes.projects.view, name=project_name) }}"
+            <a href="{{ as_url(get.projects.view, name=project_name) }}"
                class="text-decoration-none text-reset">{{ 
project_short_display_name }}</a>
           </h3>
           <ul>
@@ -105,7 +105,7 @@
       <ul>
         <li>
           <i class="bi bi-key"></i>
-          <a href="{{ as_url(routes.keys.keys) }}">Public keys</a>
+          <a href="{{ as_url(get.keys.keys) }}">Public keys</a>
         </li>
         <li>
           <i class="bi bi-key"></i>
diff --git a/atr/templates/index-committer.html 
b/atr/templates/index-committer.html
index 4786a2f..7bcbb94 100644
--- a/atr/templates/index-committer.html
+++ b/atr/templates/index-committer.html
@@ -87,10 +87,10 @@
         </h2>
 
         <p class="mb-3">
-          <a href="{{ as_url(routes.projects.view, name=project.name) }}"
+          <a href="{{ as_url(get.projects.view, name=project.name) }}"
              class="text-decoration-none me-2">About this project</a>
           <span class="text-muted me-2">/</span>
-          <a href="{{ as_url(routes.projects.add_project, 
committee_name=project.committee.name) }}"
+          <a href="{{ as_url(get.projects.add_project, 
committee_name=project.committee.name) }}"
              class="text-decoration-none me-2">Create a sibling project</a>
           {% if completed_releases %}
             <span class="text-muted me-2">/</span>
diff --git a/atr/templates/keys-add.html b/atr/templates/keys-add.html
index fc5ebb0..908ed5c 100644
--- a/atr/templates/keys-add.html
+++ b/atr/templates/keys-add.html
@@ -10,7 +10,7 @@
 
 {% block content %}
   <p>
-    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+    <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
   </p>
 
   <div class="my-4">
@@ -21,7 +21,7 @@
 
     <form method="post"
           class="atr-canary py-4 px-5"
-          action="{{ as_url(routes.keys.add) }}"
+          action="{{ as_url(post.keys.add) }}"
           novalidate>
       {{ form.hidden_tag() }}
 
@@ -61,7 +61,7 @@
 
       <div class="mt-4 col-md-9 offset-md-3 px-1">
         {{ form.submit(class_='btn btn-primary') }}
-        <a href="{{ as_url(routes.keys.keys) }}"
+        <a href="{{ as_url(get.keys.keys) }}"
            class="btn btn-link text-secondary">Cancel</a>
       </div>
     </form>
diff --git a/atr/templates/keys-details.html b/atr/templates/keys-details.html
index 08bce30..b55833d 100644
--- a/atr/templates/keys-details.html
+++ b/atr/templates/keys-details.html
@@ -10,7 +10,7 @@
 
 {% block content %}
   <p>
-    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+    <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
   </p>
 
   <h1>OpenPGP key details</h1>
diff --git a/atr/templates/keys-review.html b/atr/templates/keys-review.html
index 68e2bbe..ff5d441 100644
--- a/atr/templates/keys-review.html
+++ b/atr/templates/keys-review.html
@@ -20,9 +20,8 @@
   <p>Review your public keys used for signing release artifacts.</p>
 
   <div class="d-flex gap-3 mb-4">
-    <a href="{{ as_url(routes.keys.add) }}" class="btn 
btn-outline-primary">Add your OpenPGP key</a>
-    <a href="{{ as_url(routes.keys.ssh_add) }}"
-       class="btn btn-outline-primary">Add your SSH key</a>
+    <a href="{{ as_url(get.keys.add) }}" class="btn btn-outline-primary">Add 
your OpenPGP key</a>
+    <a href="{{ as_url(get.keys.ssh_add) }}" class="btn 
btn-outline-primary">Add your SSH key</a>
   </div>
 
   <h3>Your OpenPGP keys</h3>
@@ -41,7 +40,7 @@
           {% for key in user_keys %}
             <tr class="page-user-openpgp-key">
               <td class="text-break px-2 align-middle">
-                <a href="{{ as_url(routes.keys.details, 
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+                <a href="{{ as_url(get.keys.details, 
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
               </td>
               <td class="text-break px-2 align-middle">
                 {% if key.committees %}
@@ -52,7 +51,7 @@
               </td>
               <td class="px-2">
                 <form method="post"
-                      action="{{ as_url(routes.keys.delete) }}"
+                      action="{{ as_url(post.keys.delete) }}"
                       class="m-0"
                       onsubmit="return confirm('Are you sure you want to 
delete this OpenPGP key?');">
                   {{ delete_form.hidden_tag() }}
@@ -96,7 +95,7 @@
             </details>
 
             <form method="post"
-                  action="{{ as_url(routes.keys.delete) }}"
+                  action="{{ as_url(post.keys.delete) }}"
                   class="mt-3"
                   onsubmit="return confirm('Are you sure you want to delete 
this SSH key?');">
               {{ delete_form.hidden_tag() }}
@@ -116,8 +115,7 @@
 
   <h2 id="your-committee-keys">Your committee's keys</h2>
   <div class="mb-4">
-    <a href="{{ as_url(routes.keys.upload) }}"
-       class="btn btn-outline-primary">Upload a KEYS file</a>
+    <a href="{{ as_url(get.keys.upload) }}" class="btn 
btn-outline-primary">Upload a KEYS file</a>
   </div>
   {% for committee in committees %}
     {% if not committee_is_standing(committee.name) %}
@@ -136,7 +134,7 @@
               {% for key in committee.public_signing_keys %}
                 <tr>
                   <td class="text-break font-monospace px-2">
-                    <a href="{{ as_url(routes.keys.details, 
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+                    <a href="{{ as_url(get.keys.details, 
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
                   </td>
                   <td class="text-break px-2">{{ 
email_from_key(key.primary_declared_uid) or 'Not specified' }}</td>
                   <td class="text-break px-2">{{ key.apache_uid or "-" }}</td>
@@ -149,7 +147,7 @@
           The <code>KEYS</code> file is automatically generated when you add 
or remove a key, but you can also use the form below to manually regenerate it.
         </p>
         <form method="post"
-              action="{{ as_url(routes.keys.update_committee_keys, 
committee_name=committee.name) }}"
+              action="{{ as_url(post.keys.update_committee_keys, 
committee_name=committee.name) }}"
               class="mb-4 d-inline-block">
           {{ update_committee_keys_form.hidden_tag() }}
 
diff --git a/atr/templates/keys-ssh-add.html b/atr/templates/keys-ssh-add.html
index eb9d24e..c6c2e56 100644
--- a/atr/templates/keys-ssh-add.html
+++ b/atr/templates/keys-ssh-add.html
@@ -10,7 +10,7 @@
 
 {% block content %}
   <p>
-    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+    <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
   </p>
 
   <h1>Add your SSH key</h1>
diff --git a/atr/templates/keys-upload.html b/atr/templates/keys-upload.html
index 6dd8d7f..36790bc 100644
--- a/atr/templates/keys-upload.html
+++ b/atr/templates/keys-upload.html
@@ -78,7 +78,7 @@
 
 {% block content %}
   <p>
-    <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
+    <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
   </p>
 
   <h1>Upload a KEYS file</h1>
@@ -241,7 +241,7 @@
 
     <div class="mt-4 col-md-9 offset-md-2">
       {{ form.submit(class_="btn btn-primary") }}
-      <a href="{{ as_url(routes.keys.keys) }}"
+      <a href="{{ as_url(get.keys.keys) }}"
          class="btn btn-link text-secondary">Cancel</a>
     </div>
   </form>
diff --git a/atr/templates/phase-view.html b/atr/templates/phase-view.html
index 486bd9c..375e785 100644
--- a/atr/templates/phase-view.html
+++ b/atr/templates/phase-view.html
@@ -103,7 +103,7 @@
                       {% elif phase_key == "candidate" %}
                         {% set file_url = as_url(get.candidate.view_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
                       {% elif phase_key == "preview" %}
-                        {% set file_url = as_url(routes.preview.view_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
+                        {% set file_url = as_url(get.preview.view_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
                       {% elif phase_key == "release" %}
                         {% set file_url = as_url(routes.release.view_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
                       {% else %}
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 12455c8..633153f 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -80,7 +80,7 @@
       <div class="card-body">
         {% if can_edit and policy_form %}
           <form method="post"
-                action="{{ as_url(routes.projects.view, name=project.name) }}"
+                action="{{ as_url(post.projects.view, name=project.name) }}"
                 class="atr-canary py-4 px-5"
                 novalidate>
             {{ policy_form.hidden_tag() if policy_form.hidden_tag }}
@@ -330,7 +330,7 @@
       <div class="card-body">
         {{ forms.errors_summary(metadata_form) }}
         <form method="post"
-              action="{{ as_url(routes.projects.view, name=project.name) }}"
+              action="{{ as_url(post.projects.view, name=project.name) }}"
               class="mb-3">
           {{ metadata_form.hidden_tag() if metadata_form.hidden_tag }}
           {{ metadata_form.project_name() }}
@@ -373,7 +373,7 @@
       <div class="card-body">
         {{ forms.errors_summary(metadata_form) }}
         <form method="post"
-              action="{{ as_url(routes.projects.view, name=project.name) }}"
+              action="{{ as_url(post.projects.view, name=project.name) }}"
               class="mb-3">
           {{ metadata_form.hidden_tag() if metadata_form.hidden_tag }}
           {{ metadata_form.project_name() }}
@@ -453,7 +453,7 @@
       <h2>Preview releases</h2>
       <div class="d-flex flex-wrap gap-2 mb-4">
         {% for preview in previews %}
-          <a href="{{ as_url(routes.preview.view, project_name=project.name, 
version_name=preview.version) }}"
+          <a href="{{ as_url(get.preview.view, project_name=project.name, 
version_name=preview.version) }}"
              class="btn btn-sm btn-outline-warning py-2 px-3"
              title="View preview {{ project.name }} {{ preview.version }}">
             {{ project.name }} {{ preview.version }}
@@ -494,7 +494,7 @@
       <h2>Actions</h2>
       <div class="my-3">
         <form method="post"
-              action="{{ as_url(routes.projects.delete) }}"
+              action="{{ as_url(post.projects.delete) }}"
               class="d-inline-block m-0"
               onsubmit="return confirm('Are you sure you want to delete the 
project \'{{ project.display_name }}\'? This cannot be undone.');">
           {{ empty_form.hidden_tag() }}
@@ -510,7 +510,7 @@
     {% endif %}
     {% if (is_committee_member or is_admin) %}
       <p>
-        <a href="{{ as_url(routes.projects.add_project, 
committee_name=project.committee.name) }}"
+        <a href="{{ as_url(get.projects.add_project, 
committee_name=project.committee.name) }}"
            class="btn btn-sm btn-outline-primary">Create a sibling project</a>
       </p>
     {% endif %}
diff --git a/atr/templates/projects.html b/atr/templates/projects.html
index 624efde..b59156c 100644
--- a/atr/templates/projects.html
+++ b/atr/templates/projects.html
@@ -41,7 +41,7 @@
       {% endif %}
       <div class="col">
         <div class="card h-100 shadow-sm atr-cursor-pointer page-project-card 
{{ '' if project.status.value.lower() == 'active' else 'bg-body-secondary' }}"
-             data-project-url="{{ as_url(routes.projects.view, 
name=project.name) }}"
+             data-project-url="{{ as_url(get.projects.view, name=project.name) 
}}"
              data-is-participant="{{ 'true' if is_part else 'false' }}">
           <div class="card-body">
             <div class="row g-1">
@@ -80,7 +80,7 @@
             {% if project.created_by == current_user.uid %}
               <div class="mt-3">
                 <form method="post"
-                      action="{{ as_url(routes.projects.delete) }}"
+                      action="{{ as_url(post.projects.delete) }}"
                       class="d-inline-block m-0"
                       onsubmit="return confirm('Are you sure you want to 
delete the project \'{{ project.display_name }}\'? This cannot be undone.');">
                   {{ empty_form.hidden_tag() }}
diff --git a/atr/templates/release-select.html 
b/atr/templates/release-select.html
index f60b710..111f24f 100644
--- a/atr/templates/release-select.html
+++ b/atr/templates/release-select.html
@@ -6,7 +6,7 @@
 
 {% block content %}
   <p class="atr-breadcrumbs">
-    <a href="{{ as_url(routes.projects.select) }}" 
class="atr-back-link">Select a project</a>
+    <a href="{{ as_url(get.projects.select) }}" class="atr-back-link">Select a 
project</a>
     <span>→</span> Select an {{ project.display_name }} release
   </p>
 
diff --git a/atr/templates/upload-selected.html 
b/atr/templates/upload-selected.html
index cc6cfd2..ed9cc83 100644
--- a/atr/templates/upload-selected.html
+++ b/atr/templates/upload-selected.html
@@ -120,7 +120,7 @@
   {% if key_count == 0 %}
     <div class="alert alert-warning">
       <p class="mb-0">
-        We have no SSH keys on file for you, so you cannot yet use this 
command. Please <a href="{{ as_url(routes.keys.ssh_add) }}">add your SSH 
key</a>.
+        We have no SSH keys on file for you, so you cannot yet use this 
command. Please <a href="{{ as_url(get.keys.ssh_add) }}">add your SSH key</a>.
       </p>
     </div>
   {% endif %}
diff --git a/atr/templates/user-ssh-keys.html b/atr/templates/user-ssh-keys.html
index 69a1bb5..95662d9 100644
--- a/atr/templates/user-ssh-keys.html
+++ b/atr/templates/user-ssh-keys.html
@@ -10,8 +10,8 @@
   {% set key_parts = key.key.split(' ', 2) %}
   {% set key_comment = key_parts[2] if key_parts|length > 2 else 'key' %}
   <p>
-    We have the SSH key <a href="{{ as_url(routes.keys.keys, 
_anchor='ssh-key-' + key.fingerprint) }}"
-    title="{{ key.fingerprint }}"><code>{{- key_comment | trim -}}</code></a> 
on file for you. You can also <a href="{{ as_url(routes.keys.ssh_add) }}">add 
another SSH key</a>.
+    We have the SSH key <a href="{{ as_url(get.keys.keys, _anchor='ssh-key-' + 
key.fingerprint) }}"
+    title="{{ key.fingerprint }}"><code>{{- key_comment | trim -}}</code></a> 
on file for you. You can also <a href="{{ as_url(get.keys.ssh_add) }}">add 
another SSH key</a>.
   </p>
 {% elif key_count > 1 %}
   <p>We have the following SSH keys on file for you:</p>
@@ -20,12 +20,12 @@
       {% set key_parts = key.key.split(' ', 2) %}
       {% set key_comment = key_parts[2] if key_parts|length > 2 else 'key' %}
       <li>
-        <a href="{{ as_url(routes.keys.keys, _anchor='ssh-key-' + 
key.fingerprint) }}"
+        <a href="{{ as_url(get.keys.keys, _anchor='ssh-key-' + 
key.fingerprint) }}"
            title="{{ key.fingerprint }}"><code>{{- key_comment | trim 
-}}</code></a>
       </li>
     {% endfor %}
   </ul>
   <p>
-    You can also <a href="{{ as_url(routes.keys.ssh_add) }}">add another SSH 
key</a>.
+    You can also <a href="{{ as_url(get.keys.ssh_add) }}">add another SSH 
key</a>.
   </p>
 {% endif %}
diff --git a/atr/templates/voting-selected-revision.html 
b/atr/templates/voting-selected-revision.html
index 76e59f4..d7436bd 100644
--- a/atr/templates/voting-selected-revision.html
+++ b/atr/templates/voting-selected-revision.html
@@ -38,7 +38,7 @@
       <i class="bi bi-exclamation-triangle-fill"></i>
       <strong>Warning:</strong>
       The KEYS file is missing.
-      Please autogenerate one on the <a href="{{ as_url(routes.keys.keys) 
}}#committee-{{ release.committee.name|slugify }}">KEYS page</a>.
+      Please autogenerate one on the <a href="{{ as_url(get.keys.keys) 
}}#committee-{{ release.committee.name|slugify }}">KEYS page</a>.
     </div>
   {% endif %}
 


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

Reply via email to