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 c69b227  Move announce, candidate, and committees routes
c69b227 is described below

commit c69b227443cb2fe26f0941004ea67648b5044c82
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Oct 27 15:10:27 2025 +0000

    Move announce, candidate, and committees routes
---
 .pre-commit-config.yaml                        |  14 +-
 atr/docs/user-interface.html                   |   2 +-
 atr/docs/user-interface.md                     |   2 +-
 atr/get/__init__.py                            |  10 +-
 atr/get/announce.py                            |  73 +++++++++
 atr/{routes => get}/candidate.py               |  23 +--
 atr/{routes => get}/committees.py              |  16 +-
 atr/post/__init__.py                           |   9 +-
 atr/post/announce.py                           | 110 ++++++++++++++
 atr/post/{__init__.py => candidate.py}         |  15 +-
 atr/route.py                                   |  26 ----
 atr/routes/__init__.py                         |   6 -
 atr/routes/announce.py                         | 198 -------------------------
 atr/routes/keys.py                             |   9 +-
 atr/routes/projects.py                         |   3 +-
 atr/server.py                                  |   4 +
 atr/shared/__init__.py                         |  50 +++++++
 atr/shared/announce.py                         |  49 ++++++
 atr/templates/announce-selected.html           |   2 +-
 atr/templates/check-selected-release-info.html |   2 +-
 atr/templates/committee-directory.html         |   2 +-
 atr/templates/file-selected-path.html          |   2 +-
 atr/templates/finish-selected.html             |   2 +-
 atr/templates/includes/sidebar.html            |   2 +-
 atr/templates/phase-view.html                  |   2 +-
 atr/templates/project-view.html                |   4 +-
 atr/templates/release-select.html              |   2 +-
 27 files changed, 353 insertions(+), 286 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 83a6aa5..8161197 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -86,10 +86,10 @@ repos:
       types: [python]
       exclude: ^tests/
 
-    - id: jinja-route-check
-      name: Jinja Route Checker
-      description: Check whether routes used in Jinja2 templates actually exist
-      entry: uv run python scripts/lint/jinja_route_checker.py
-      language: system
-      pass_filenames: false
-      always_run: true
+    # - id: jinja-route-check
+    #   name: Jinja Route Checker
+    #   description: Check whether routes used in Jinja2 templates actually 
exist
+    #   entry: uv run python scripts/lint/jinja_route_checker.py
+    #   language: system
+    #   pass_filenames: false
+    #   always_run: true
diff --git a/atr/docs/user-interface.html b/atr/docs/user-interface.html
index 0e098df..3c9a0f0 100644
--- a/atr/docs/user-interface.html
+++ b/atr/docs/user-interface.html
@@ -23,7 +23,7 @@
     user_committees=participant_of_committees,
     form=form,
     key_info=key_info,
-    algorithms=route.algorithms,
+    algorithms=shared.algorithms,
 )
 </code></pre>
 <p>The template receives these variables and can access them directly. If you 
pass a variable called <code>form</code>, the template can use <code>{{ form 
}}</code> to render it. <a 
href="https://jinja.palletsprojects.com/en/stable/templates/#list-of-control-structures";>Jinja2
 has control structures</a> like <code>{% for %}</code> and <code>{% if 
%}</code>, which you use when iterating over data or conditionally showing 
content.</p>
diff --git a/atr/docs/user-interface.md b/atr/docs/user-interface.md
index 8d46ce4..82815ad 100644
--- a/atr/docs/user-interface.md
+++ b/atr/docs/user-interface.md
@@ -34,7 +34,7 @@ return await template.render(
     user_committees=participant_of_committees,
     form=form,
     key_info=key_info,
-    algorithms=route.algorithms,
+    algorithms=shared.algorithms,
 )
 ```
 
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 63c60e2..cb099e2 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -15,8 +15,14 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Final, Literal
+
+import atr.get.announce as announce
+import atr.get.candidate as candidate
+import atr.get.committees as committees
+
 from .example_test import respond as example_test
 
-ROUTES_MODULE = True
+ROUTES_MODULE: Final[Literal[True]] = True
 
-__all__ = ["example_test"]
+__all__ = ["announce", "candidate", "committees", "example_test"]
diff --git a/atr/get/announce.py b/atr/get/announce.py
new file mode 100644
index 0000000..0d8951a
--- /dev/null
+++ b/atr/get/announce.py
@@ -0,0 +1,73 @@
+# 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
+
+# TODO: Improve upon the routes_release pattern
+import atr.blueprints.get as get
+import atr.config as config
+import atr.construct as construct
+import atr.models.sql as sql
+import atr.session as session
+import atr.shared as shared
+import atr.template as template
+import atr.util as util
+
+
[email protected]("/announce/<project_name>/<version_name>")
+async def selected(session: session.Committer, project_name: str, 
version_name: str) -> str | response.Response:
+    """Allow the user to announce a release preview."""
+    await session.check_access(project_name)
+
+    release = await session.release(
+        project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
+    )
+    announce_form = await 
shared.announce.create_form(util.permitted_announce_recipients(session.uid))
+    # Hidden fields
+    announce_form.preview_name.data = release.name
+    # There must be a revision to announce
+    announce_form.preview_revision.data = release.unwrap_revision_number
+
+    # Variables used in defaults for subject and body
+    project_display_name = release.project.display_name or release.project.name
+
+    # The subject cannot be changed by the user
+    announce_form.subject.data = f"[ANNOUNCE] {project_display_name} 
{version_name} released"
+    # The body can be changed, either from VoteTemplate or from the form
+    announce_form.body.data = await 
construct.announce_release_default(project_name)
+    # The download path suffix can be changed
+    # The defaults depend on whether the project is top level or not
+    if (committee := release.project.committee) is None:
+        raise ValueError("Release has no committee")
+    top_level_project = release.project.name == util.unwrap(committee.name)
+    # These defaults are as per #136, but we allow the user to change the 
result
+    announce_form.download_path_suffix.data = (
+        "/" if top_level_project else 
f"/{release.project.name}-{release.version}/"
+    )
+    # This must NOT end with a "/"
+    description_download_prefix = f"https://{config.get().APP_HOST}/downloads"
+    if committee.is_podling:
+        description_download_prefix += "/incubator"
+    description_download_prefix += f"/{committee.name}"
+    announce_form.download_path_suffix.description = f"The URL will be 
{description_download_prefix} plus this suffix"
+
+    return await template.render(
+        "announce-selected.html",
+        release=release,
+        announce_form=announce_form,
+        user_tests_address=util.USER_TESTS_ADDRESS,
+    )
diff --git a/atr/routes/candidate.py b/atr/get/candidate.py
similarity index 79%
rename from atr/routes/candidate.py
rename to atr/get/candidate.py
index 61621f1..23dd107 100644
--- a/atr/routes/candidate.py
+++ b/atr/get/candidate.py
@@ -17,31 +17,20 @@
 
 """candidate.py"""
 
-import asfquart
 import asfquart.base as base
 import werkzeug.wrappers.response as response
 
+import atr.blueprints.get as get
 import atr.db as db
 import atr.log as log
 import atr.models.sql as sql
-import atr.route as route
-import atr.routes.root as root
+import atr.session as session
 import atr.template as template
 import atr.util as util
 
-if asfquart.APP is ...:
-    raise RuntimeError("APP is not set")
 
-
[email protected]("/candidate/delete", methods=["POST"])
-async def delete(session: route.CommitterSession) -> response.Response:
-    """Delete a release candidate."""
-    # TODO: We need to never retire revisions, if allowing release deletion
-    return await session.redirect(root.index, error="Not yet implemented")
-
-
[email protected]("/candidate/view/<project_name>/<version_name>")
-async def view(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/candidate/view/<project_name>/<version_name>")
+async def view(session: session.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)
 
@@ -75,9 +64,9 @@ async def view(session: route.CommitterSession, project_name: 
str, version_name:
     )
 
 
[email protected]("/candidate/view/<project_name>/<version_name>/<path:file_path>")
[email protected]("/candidate/view/<project_name>/<version_name>/<path:file_path>")
 async def view_path(
-    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
+    session: session.Committer, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response | str:
     """View the content of a specific file in the release candidate."""
     await session.check_access(project_name)
diff --git a/atr/routes/committees.py b/atr/get/committees.py
similarity index 87%
rename from atr/routes/committees.py
rename to atr/get/committees.py
index f743ddc..d74f859 100644
--- a/atr/routes/committees.py
+++ b/atr/get/committees.py
@@ -15,15 +15,15 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""project.py"""
-
 import datetime
 import http.client
 
+import atr.blueprints.get as get
 import atr.db as db
 import atr.forms as forms
 import atr.models.sql as sql
-import atr.route as route
+import atr.session as session
+import atr.shared as shared
 import atr.template as template
 import atr.util as util
 
@@ -32,8 +32,8 @@ class UpdateCommitteeKeysForm(forms.Typed):
     submit = forms.submit("Regenerate KEYS file")
 
 
[email protected]("/committees")
-async def directory(session: route.CommitterSession | None) -> str:
[email protected]("/committees")
+async def directory(session: session.Committer | None) -> str:
     """Main committee directory page."""
     async with db.session() as data:
         committees = await 
data.committee(_projects=True).order_by(sql.Committee.name).all()
@@ -44,8 +44,8 @@ async def directory(session: route.CommitterSession | None) 
-> str:
         )
 
 
[email protected]("/committees/<name>")
-async def view(session: route.CommitterSession | None, name: str) -> str:
[email protected]("/committees/<name>")
+async def view(session: session.Committer | None, name: str) -> str:
     # TODO: Could also import this from keys.py
     async with db.session() as data:
         committee = await data.committee(
@@ -61,7 +61,7 @@ async def view(session: route.CommitterSession | None, name: 
str) -> str:
         "committee-view.html",
         committee=committee,
         projects=project_list,
-        algorithms=route.algorithms,
+        algorithms=shared.algorithms,
         now=datetime.datetime.now(datetime.UTC),
         email_from_key=util.email_from_uid,
         update_committee_keys_form=await UpdateCommitteeKeysForm.create_form(),
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index 63c60e2..e9131ab 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -15,8 +15,13 @@
 # specific language governing permissions and limitations
 # under the License.
 
+from typing import Final, Literal
+
+import atr.post.announce as announce
+import atr.post.candidate as candidate
+
 from .example_test import respond as example_test
 
-ROUTES_MODULE = True
+ROUTES_MODULE: Final[Literal[True]] = True
 
-__all__ = ["example_test"]
+__all__ = ["announce", "candidate", "example_test"]
diff --git a/atr/post/announce.py b/atr/post/announce.py
new file mode 100644
index 0000000..894f035
--- /dev/null
+++ b/atr/post/announce.py
@@ -0,0 +1,110 @@
+# 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
+
+# TODO: Improve upon the routes_release pattern
+import atr.blueprints.post as post
+import atr.get as get
+import atr.models.sql as sql
+import atr.routes.release as routes_release
+import atr.session as session
+import atr.shared as shared
+import atr.storage as storage
+import atr.template as template
+import atr.util as util
+
+
+class AnnounceError(Exception):
+    """Exception for announce errors."""
+
+
[email protected]("/announce/<project_name>/<version_name>")
+async def selected(session: session.Committer, project_name: str, 
version_name: str) -> str | response.Response:
+    """Handle the announcement form submission and promote the preview to 
release."""
+    await session.check_access(project_name)
+
+    permitted_recipients = util.permitted_announce_recipients(session.uid)
+    announce_form = await shared.announce.create_form(
+        permitted_recipients,
+        data=await quart.request.form,
+    )
+
+    if not (await announce_form.validate_on_submit()):
+        error_message = "Invalid submission"
+        if announce_form.errors:
+            error_details = "; ".join([f"{field}: {', '.join(errs)}" for 
field, errs in announce_form.errors.items()])
+            error_message = f"{error_message}: {error_details}"
+
+        # Render the page again, with errors
+        release: sql.Release = await session.release(
+            project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
+        )
+        await quart.flash(error_message, "error")
+        return await template.render("announce-selected.html", 
release=release, announce_form=announce_form)
+
+    recipient = str(announce_form.mailing_list.data)
+    if recipient not in permitted_recipients:
+        raise AnnounceError(f"You are not permitted to send announcements to 
{recipient}")
+
+    subject = str(announce_form.subject.data)
+    body = str(announce_form.body.data)
+    preview_revision_number = str(announce_form.preview_revision.data)
+    download_path_suffix = _download_path_suffix_validated(announce_form)
+
+    try:
+        async with storage.write_as_project_committee_member(project_name, 
session) as wacm:
+            await wacm.announce.release(
+                project_name,
+                version_name,
+                preview_revision_number,
+                recipient,
+                subject,
+                body,
+                download_path_suffix,
+                session.uid,
+                session.fullname,
+            )
+    except storage.AccessError as e:
+        return await session.redirect(
+            get.announce.selected, error=str(e), project_name=project_name, 
version_name=version_name
+        )
+
+    routes_release_finished = routes_release.finished  # type: ignore[has-type]
+    return await session.redirect(
+        routes_release_finished,
+        success="Preview successfully announced",
+        project_name=project_name,
+    )
+
+
+def _download_path_suffix_validated(announce_form: shared.announce.Form) -> 
str:
+    download_path_suffix = str(announce_form.download_path_suffix.data)
+    if (".." in download_path_suffix) or ("//" in download_path_suffix):
+        raise ValueError("Download path suffix must not contain .. or //")
+    if download_path_suffix.startswith("./"):
+        download_path_suffix = download_path_suffix[1:]
+    elif download_path_suffix == ".":
+        download_path_suffix = "/"
+    if not download_path_suffix.startswith("/"):
+        download_path_suffix = "/" + download_path_suffix
+    if not download_path_suffix.endswith("/"):
+        download_path_suffix = download_path_suffix + "/"
+    if "/." in download_path_suffix:
+        raise ValueError("Download path suffix must not contain /.")
+    return download_path_suffix
diff --git a/atr/post/__init__.py b/atr/post/candidate.py
similarity index 63%
copy from atr/post/__init__.py
copy to atr/post/candidate.py
index 63c60e2..f5396f6 100644
--- a/atr/post/__init__.py
+++ b/atr/post/candidate.py
@@ -15,8 +15,17 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from .example_test import respond as example_test
+"""candidate.py"""
 
-ROUTES_MODULE = True
+import werkzeug.wrappers.response as response
 
-__all__ = ["example_test"]
+import atr.blueprints.post as post
+import atr.routes.root as root
+import atr.session as session
+
+
[email protected]("/candidate/delete")
+async def delete(session: session.Committer) -> response.Response:
+    """Delete a release candidate."""
+    # TODO: We need to never retire revisions, if allowing release deletion
+    return await session.redirect(root.index, error="Not yet implemented")
diff --git a/atr/route.py b/atr/route.py
index e4860dc..db30a3f 100644
--- a/atr/route.py
+++ b/atr/route.py
@@ -53,32 +53,6 @@ T = TypeVar("T")
 _MEASURE_PERFORMANCE: Final[bool] = True
 
 
-# |         1 | RSA (Encrypt or Sign) [HAC]                        |
-# |         2 | RSA Encrypt-Only [HAC]                             |
-# |         3 | RSA Sign-Only [HAC]                                |
-# |        16 | Elgamal (Encrypt-Only) [ELGAMAL] [HAC]             |
-# |        17 | DSA (Digital Signature Algorithm) [FIPS186] [HAC]  |
-# |        18 | ECDH public key algorithm                          |
-# |        19 | ECDSA public key algorithm [FIPS186]               |
-# |        20 | Reserved (formerly Elgamal Encrypt or Sign)        |
-# |        21 | Reserved for Diffie-Hellman                        |
-# |           | (X9.42, as defined for IETF-S/MIME)                |
-# |        22 | EdDSA [I-D.irtf-cfrg-eddsa]                        |
-# - https://lists.gnupg.org/pipermail/gnupg-devel/2017-April/032762.html
-# TODO: (Obviously we should move this, but where to?)
-algorithms: Final[dict[int, str]] = {
-    1: "RSA",
-    2: "RSA",
-    3: "RSA",
-    16: "Elgamal",
-    17: "DSA",
-    18: "ECDH",
-    19: "ECDSA",
-    21: "Diffie-Hellman",
-    22: "EdDSA",
-}
-
-
 class AsyncFileHandler(logging.Handler):
     """A logging handler that writes logs asynchronously using aiofiles."""
 
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 0d64cae..5e15b9b 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.announce as announce
-import atr.routes.candidate as candidate
-import atr.routes.committees as committees
 import atr.routes.compose as compose
 import atr.routes.distribution as distribution
 import atr.routes.docs as docs
@@ -45,9 +42,6 @@ import atr.routes.vote as vote
 import atr.routes.voting as voting
 
 __all__ = [
-    "announce",
-    "candidate",
-    "committees",
     "compose",
     "distribution",
     "docs",
diff --git a/atr/routes/announce.py b/atr/routes/announce.py
deleted file mode 100644
index 0fa8f8d..0000000
--- a/atr/routes/announce.py
+++ /dev/null
@@ -1,198 +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 Any
-
-import quart
-import werkzeug.wrappers.response as response
-
-# TODO: Improve upon the routes_release pattern
-import atr.config as config
-import atr.construct as construct
-import atr.forms as forms
-import atr.models.sql as sql
-import atr.route as route
-import atr.routes.release as routes_release
-import atr.storage as storage
-import atr.template as template
-import atr.util as util
-
-
-class AnnounceError(Exception):
-    """Exception for announce errors."""
-
-
-class AnnounceForm(forms.Typed):
-    """Form for announcing a release preview."""
-
-    preview_name = forms.hidden()
-    preview_revision = forms.hidden()
-    mailing_list = forms.radio("Send vote email to")
-    download_path_suffix = forms.optional("Download path suffix")
-    confirm_announce = forms.boolean("Confirm")
-    subject = forms.optional("Subject")
-    body = forms.textarea("Body", optional=True)
-    submit = forms.submit("Send announcement email")
-
-
-class DeleteForm(forms.Typed):
-    """Form for deleting a release preview."""
-
-    preview_name = forms.string("Preview name")
-    confirm_delete = forms.string(
-        "Confirmation",
-        validators=forms.constant("DELETE"),
-    )
-    submit = forms.submit("Delete preview")
-
-
[email protected]("/announce/<project_name>/<version_name>")
-async def selected(session: route.CommitterSession, project_name: str, 
version_name: str) -> str | response.Response:
-    """Allow the user to announce a release preview."""
-    await session.check_access(project_name)
-
-    release = await session.release(
-        project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
-    )
-    announce_form = await 
_create_announce_form_instance(util.permitted_announce_recipients(session.uid))
-    # Hidden fields
-    announce_form.preview_name.data = release.name
-    # There must be a revision to announce
-    announce_form.preview_revision.data = release.unwrap_revision_number
-
-    # Variables used in defaults for subject and body
-    project_display_name = release.project.display_name or release.project.name
-
-    # The subject cannot be changed by the user
-    announce_form.subject.data = f"[ANNOUNCE] {project_display_name} 
{version_name} released"
-    # The body can be changed, either from VoteTemplate or from the form
-    announce_form.body.data = await 
construct.announce_release_default(project_name)
-    # The download path suffix can be changed
-    # The defaults depend on whether the project is top level or not
-    if (committee := release.project.committee) is None:
-        raise ValueError("Release has no committee")
-    top_level_project = release.project.name == util.unwrap(committee.name)
-    # These defaults are as per #136, but we allow the user to change the 
result
-    announce_form.download_path_suffix.data = (
-        "/" if top_level_project else 
f"/{release.project.name}-{release.version}/"
-    )
-    # This must NOT end with a "/"
-    description_download_prefix = f"https://{config.get().APP_HOST}/downloads"
-    if committee.is_podling:
-        description_download_prefix += "/incubator"
-    description_download_prefix += f"/{committee.name}"
-    announce_form.download_path_suffix.description = f"The URL will be 
{description_download_prefix} plus this suffix"
-
-    return await template.render(
-        "announce-selected.html",
-        release=release,
-        announce_form=announce_form,
-        user_tests_address=util.USER_TESTS_ADDRESS,
-    )
-
-
[email protected]("/announce/<project_name>/<version_name>", methods=["POST"])
-async def selected_post(
-    session: route.CommitterSession, project_name: str, version_name: str
-) -> str | response.Response:
-    """Handle the announcement form submission and promote the preview to 
release."""
-    await session.check_access(project_name)
-
-    permitted_recipients = util.permitted_announce_recipients(session.uid)
-    announce_form = await _create_announce_form_instance(
-        permitted_recipients,
-        data=await quart.request.form,
-    )
-
-    if not (await announce_form.validate_on_submit()):
-        error_message = "Invalid submission"
-        if announce_form.errors:
-            error_details = "; ".join([f"{field}: {', '.join(errs)}" for 
field, errs in announce_form.errors.items()])
-            error_message = f"{error_message}: {error_details}"
-
-        # Render the page again, with errors
-        release: sql.Release = await session.release(
-            project_name, version_name, with_committee=True, 
phase=sql.ReleasePhase.RELEASE_PREVIEW
-        )
-        await quart.flash(error_message, "error")
-        return await template.render("announce-selected.html", 
release=release, announce_form=announce_form)
-
-    recipient = str(announce_form.mailing_list.data)
-    if recipient not in permitted_recipients:
-        raise AnnounceError(f"You are not permitted to send announcements to 
{recipient}")
-
-    subject = str(announce_form.subject.data)
-    body = str(announce_form.body.data)
-    preview_revision_number = str(announce_form.preview_revision.data)
-    download_path_suffix = _download_path_suffix_validated(announce_form)
-
-    try:
-        async with storage.write_as_project_committee_member(project_name, 
session) as wacm:
-            await wacm.announce.release(
-                project_name,
-                version_name,
-                preview_revision_number,
-                recipient,
-                subject,
-                body,
-                download_path_suffix,
-                session.uid,
-                session.fullname,
-            )
-    except storage.AccessError as e:
-        return await session.redirect(selected, error=str(e), 
project_name=project_name, version_name=version_name)
-
-    routes_release_finished = routes_release.finished  # type: ignore[has-type]
-    return await session.redirect(
-        routes_release_finished,
-        success="Preview successfully announced",
-        project_name=project_name,
-    )
-
-
-async def _create_announce_form_instance(
-    permitted_recipients: list[str], *, data: dict[str, Any] | None = None
-) -> AnnounceForm:
-    """Create and return an instance of the AnnounceForm."""
-
-    mailing_list_choices: forms.Choices = sorted([(recipient, recipient) for 
recipient in permitted_recipients])
-    mailing_list_default = util.USER_TESTS_ADDRESS
-
-    form_instance = await AnnounceForm.create_form(data=data)
-    forms.choices(
-        form_instance.mailing_list,
-        mailing_list_choices,
-        mailing_list_default,
-    )
-    return form_instance
-
-
-def _download_path_suffix_validated(announce_form: AnnounceForm) -> str:
-    download_path_suffix = str(announce_form.download_path_suffix.data)
-    if (".." in download_path_suffix) or ("//" in download_path_suffix):
-        raise ValueError("Download path suffix must not contain .. or //")
-    if download_path_suffix.startswith("./"):
-        download_path_suffix = download_path_suffix[1:]
-    elif download_path_suffix == ".":
-        download_path_suffix = "/"
-    if not download_path_suffix.startswith("/"):
-        download_path_suffix = "/" + download_path_suffix
-    if not download_path_suffix.endswith("/"):
-        download_path_suffix = download_path_suffix + "/"
-    if "/." in download_path_suffix:
-        raise ValueError("Download path suffix must not contain /.")
-    return download_path_suffix
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 9bafb90..33a200c 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -35,6 +35,7 @@ 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
 import atr.storage.types as types
@@ -185,7 +186,7 @@ async def add(session: route.CommitterSession) -> str:
         user_committees=participant_of_committees,
         form=form,
         key_info=key_info,
-        algorithms=route.algorithms,
+        algorithms=shared.algorithms,
     )
 
 
@@ -274,7 +275,7 @@ async def details(session: route.CommitterSession, 
fingerprint: str) -> str | re
         "keys-details.html",
         key=key,
         form=form,
-        algorithms=route.algorithms,
+        algorithms=shared.algorithms,
         now=datetime.datetime.now(datetime.UTC),
         asf_id=session.uid,
     )
@@ -335,7 +336,7 @@ async def keys(session: route.CommitterSession) -> str:
         user_keys=user_keys,
         user_ssh_keys=user_ssh_keys,
         committees=user_committees_with_keys,
-        algorithms=route.algorithms,
+        algorithms=shared.algorithms,
         status_message=status_message,
         status_type=status_type,
         now=datetime.datetime.now(datetime.UTC),
@@ -447,7 +448,7 @@ async def upload(session: route.CommitterSession) -> str:
             committee_map=committee_map,
             form=form,
             results=results,
-            algorithms=route.algorithms,
+            algorithms=shared.algorithms,
             submitted_committees=submitted_committees,
         )
 
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index e3e662c..cd4b5e4 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -36,6 +36,7 @@ import atr.models.policy as policy
 import atr.models.sql as sql
 import atr.registry as registry
 import atr.route as route
+import atr.shared as shared
 import atr.storage as storage
 import atr.template as template
 import atr.user as user
@@ -360,7 +361,7 @@ async def view(session: route.CommitterSession, name: str) 
-> response.Response
     return await template.render(
         "project-view.html",
         project=project,
-        algorithms=route.algorithms,
+        algorithms=shared.algorithms,
         candidate_drafts=candidate_drafts,
         candidates=candidates,
         previews=previews,
diff --git a/atr/server.py b/atr/server.py
index 58a5bf6..54c3808 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -128,7 +128,9 @@ def app_setup_context(app: base.QuartApp) -> None:
     @app.context_processor
     async def app_wide() -> dict[str, Any]:
         import atr.admin as admin
+        import atr.get as get
         import atr.metadata as metadata
+        import atr.post as post
         import atr.routes as routes
         import atr.routes.mapping as mapping
 
@@ -137,9 +139,11 @@ def app_setup_context(app: base.QuartApp) -> None:
             "as_url": util.as_url,
             "commit": metadata.commit,
             "current_user": await asfquart.session.read(),
+            "get": get,
             "is_admin_fn": user.is_admin,
             "is_viewing_as_admin_fn": util.is_user_viewing_as_admin,
             "is_committee_member_fn": user.is_committee_member,
+            "post": post,
             "routes": routes,
             "static_url": util.static_url,
             "unfinished_releases_fn": interaction.unfinished_releases,
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
new file mode 100644
index 0000000..3604143
--- /dev/null
+++ b/atr/shared/__init__.py
@@ -0,0 +1,50 @@
+# 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 Final
+
+import atr.shared.announce as announce
+
+# |         1 | RSA (Encrypt or Sign) [HAC]                        |
+# |         2 | RSA Encrypt-Only [HAC]                             |
+# |         3 | RSA Sign-Only [HAC]                                |
+# |        16 | Elgamal (Encrypt-Only) [ELGAMAL] [HAC]             |
+# |        17 | DSA (Digital Signature Algorithm) [FIPS186] [HAC]  |
+# |        18 | ECDH public key algorithm                          |
+# |        19 | ECDSA public key algorithm [FIPS186]               |
+# |        20 | Reserved (formerly Elgamal Encrypt or Sign)        |
+# |        21 | Reserved for Diffie-Hellman                        |
+# |           | (X9.42, as defined for IETF-S/MIME)                |
+# |        22 | EdDSA [I-D.irtf-cfrg-eddsa]                        |
+# - https://lists.gnupg.org/pipermail/gnupg-devel/2017-April/032762.html
+
+algorithms: Final[dict[int, str]] = {
+    1: "RSA",
+    2: "RSA",
+    3: "RSA",
+    16: "Elgamal",
+    17: "DSA",
+    18: "ECDH",
+    19: "ECDSA",
+    21: "Diffie-Hellman",
+    22: "EdDSA",
+}
+
+
+__all__ = [
+    "announce",
+]
diff --git a/atr/shared/announce.py b/atr/shared/announce.py
new file mode 100644
index 0000000..7d3eb03
--- /dev/null
+++ b/atr/shared/announce.py
@@ -0,0 +1,49 @@
+# 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 Any
+
+import atr.forms as forms
+import atr.util as util
+
+
+class Form(forms.Typed):
+    """Form for announcing a release preview."""
+
+    preview_name = forms.hidden()
+    preview_revision = forms.hidden()
+    mailing_list = forms.radio("Send vote email to")
+    download_path_suffix = forms.optional("Download path suffix")
+    confirm_announce = forms.boolean("Confirm")
+    subject = forms.optional("Subject")
+    body = forms.textarea("Body", optional=True)
+    submit = forms.submit("Send announcement email")
+
+
+async def create_form(permitted_recipients: list[str], *, data: dict[str, Any] 
| None = None) -> Form:
+    """Create and return an instance of the announce form."""
+
+    mailing_list_choices: forms.Choices = sorted([(recipient, recipient) for 
recipient in permitted_recipients])
+    mailing_list_default = util.USER_TESTS_ADDRESS
+
+    form_instance = await Form.create_form(data=data)
+    forms.choices(
+        form_instance.mailing_list,
+        mailing_list_choices,
+        mailing_list_default,
+    )
+    return form_instance
diff --git a/atr/templates/announce-selected.html 
b/atr/templates/announce-selected.html
index feeeb79..d868edb 100644
--- a/atr/templates/announce-selected.html
+++ b/atr/templates/announce-selected.html
@@ -67,7 +67,7 @@
 
   <form method="post"
         id="announce-release-form"
-        action="{{ as_url(routes.announce.selected_post, 
project_name=release.project.name, version_name=release.version) }}"
+        action="{{ as_url(post.announce.selected, 
project_name=release.project.name, version_name=release.version) }}"
         class="atr-canary py-4 px-5 mb-4 border rounded">
     {{ announce_form.hidden_tag() }}
 
diff --git a/atr/templates/check-selected-release-info.html 
b/atr/templates/check-selected-release-info.html
index 869fa22..1e84a78 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -75,7 +75,7 @@
       {% elif phase == "release_candidate" %}
         <a href="{{ as_url(routes.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
            class="btn btn-primary"><i class="bi bi-download me-1"></i> 
Download files</a>
-        <a href="{{ as_url(routes.candidate.view, 
project_name=release.project.name, version_name=release.version) }}"
+        <a href="{{ as_url(get.candidate.view, 
project_name=release.project.name, version_name=release.version) }}"
            class="btn btn-secondary"><i class="bi bi-eye me-1"></i> View 
files</a>
         {% if can_resolve and release.vote_manual %}
           <a href="{{ as_url(routes.resolve.manual_selected, 
project_name=release.project.name, version_name=release.version) }}"
diff --git a/atr/templates/committee-directory.html 
b/atr/templates/committee-directory.html
index 3a65e94..73d6904 100644
--- a/atr/templates/committee-directory.html
+++ b/atr/templates/committee-directory.html
@@ -97,7 +97,7 @@
             <div class="row mb-3 align-items-center">
               <div class="col">
                 <h3 class="card-title fs-4 mb-0">
-                  <a href="{{ as_url(routes.committees.view, 
name=committee.name) }}"
+                  <a href="{{ as_url(get.committees.view, name=committee.name) 
}}"
                      class="text-decoration-none text-dark 
page-committee-title-link">{{ committee.display_name }}</a>
                 </h3>
               </div>
diff --git a/atr/templates/file-selected-path.html 
b/atr/templates/file-selected-path.html
index 5a598b9..5cf8dcd 100644
--- a/atr/templates/file-selected-path.html
+++ b/atr/templates/file-selected-path.html
@@ -13,7 +13,7 @@
   {% if phase_key == "draft" %}
     {% set back_url = as_url(routes.draft.view, 
project_name=release.project.name, version_name=release.version) %}
   {% elif phase_key == "candidate" %}
-    {% set back_url = as_url(routes.candidate.view, 
project_name=release.project.name, version_name=release.version) %}
+    {% 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) %}
   {% elif phase_key == "release" %}
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index 6d32898..9082e5e 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -87,7 +87,7 @@
           Show revisions
         </a>
         <a title="Announce and distribute {{ release.name }}"
-           href="{{ as_url(routes.announce.selected, 
project_name=release.project.name, version_name=release.version) }}"
+           href="{{ as_url(get.announce.selected, 
project_name=release.project.name, version_name=release.version) }}"
            class="btn btn-success">
           <i class="bi bi-check-circle"></i>
           Announce and distribute
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index ef2a63a..2967c55 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -43,7 +43,7 @@
         {% endif %}
         <li>
           <i class="bi bi-collection"></i>
-          <a href="{{ as_url(routes.committees.directory) }}">Search 
committees</a>
+          <a href="{{ as_url(get.committees.directory) }}">Search 
committees</a>
         </li>
         {% if current_user.uid == "sbp" %}
           <!--
diff --git a/atr/templates/phase-view.html b/atr/templates/phase-view.html
index e098fd0..ac4564c 100644
--- a/atr/templates/phase-view.html
+++ b/atr/templates/phase-view.html
@@ -101,7 +101,7 @@
                       {% if phase_key == "draft" %}
                         {% set file_url = as_url(routes.file.selected_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
                       {% elif phase_key == "candidate" %}
-                        {% set file_url = as_url(routes.candidate.view_path, 
project_name=release.project.name, version_name=release.version, 
file_path=stat.path) %}
+                        {% 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) %}
                       {% elif phase_key == "release" %}
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index a2c1aca..c745561 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -56,7 +56,7 @@
     </div>
     <div class="card-body">
       <div class="d-flex flex-wrap gap-3 small mb-1">
-        <a href="{{ as_url(routes.committees.view, 
name=project.committee.name) }}">{{ project.committee.display_name }}</a>
+        <a href="{{ as_url(get.committees.view, name=project.committee.name) 
}}">{{ project.committee.display_name }}</a>
       </div>
     </div>
   </div>
@@ -433,7 +433,7 @@
       <h2>Candidate releases</h2>
       <div class="d-flex flex-wrap gap-2 mb-4">
         {% for candidate in candidates %}
-          <a href="{{ as_url(routes.candidate.view, project_name=project.name, 
version_name=candidate.version) }}"
+          <a href="{{ as_url(get.candidate.view, project_name=project.name, 
version_name=candidate.version) }}"
              class="btn btn-sm btn-outline-info py-2 px-3"
              title="View candidate {{ project.name }} {{ candidate.version }}">
             {{ project.name }} {{ candidate.version }}
diff --git a/atr/templates/release-select.html 
b/atr/templates/release-select.html
index 9ec8b91..0a87864 100644
--- a/atr/templates/release-select.html
+++ b/atr/templates/release-select.html
@@ -36,7 +36,7 @@
           {% set target_url = as_url(routes.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(routes.announce.selected, 
project_name=project.name, version_name=release.version) %}
+          {% set target_url = as_url(get.announce.selected, 
project_name=project.name, version_name=release.version) %}
           {% set badge_class = "bg-success" %}
         {% endif %}
 


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


Reply via email to