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 18119a0  Move docs, download, and draft routes to the new layout
18119a0 is described below

commit 18119a03c28b80ff7513d7a81cb33645b4eae71f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Oct 28 14:32:30 2025 +0000

    Move docs, download, and draft routes to the new layout
---
 atr/get/__init__.py                            |  15 ++-
 atr/{routes => get}/docs.py                    |  11 +-
 atr/{routes => get}/download.py                |  32 +++---
 atr/get/draft.py                               | 102 ++++++++++++++++++
 atr/post/__init__.py                           |   3 +-
 atr/{routes => post}/draft.py                  | 138 ++++---------------------
 atr/routes/__init__.py                         |   6 --
 atr/routes/keys.py                             |   2 +-
 atr/routes/start.py                            |   3 +-
 atr/shared/__init__.py                         |   2 +-
 atr/{get/__init__.py => shared/draft.py}       |  24 +++--
 atr/templates/check-selected-path-table.html   |   8 +-
 atr/templates/check-selected-release-info.html |   4 +-
 atr/templates/check-selected.html              |   4 +-
 atr/templates/download-all.html                |  10 +-
 atr/templates/draft-tools.html                 |   6 +-
 atr/templates/file-selected-path.html          |   2 +-
 atr/templates/finish-selected.html             |   2 +-
 atr/templates/includes/sidebar.html            |   2 +-
 atr/templates/project-view.html                |   2 +-
 atr/templates/releases-finished.html           |   2 +-
 atr/templates/upload-selected.html             |   2 +-
 atr/templates/voting-selected-revision.html    |   2 +-
 atr/web.py                                     |   4 +
 24 files changed, 206 insertions(+), 182 deletions(-)

diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 352ab48..d3b96cc 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -22,8 +22,21 @@ import atr.get.candidate as candidate
 import atr.get.committees as committees
 import atr.get.compose as compose
 import atr.get.distribution as distribution
+import atr.get.docs as docs
+import atr.get.download as download
+import atr.get.draft as draft
 import atr.get.vote as vote
 
 ROUTES_MODULE: Final[Literal[True]] = True
 
-__all__ = ["announce", "candidate", "committees", "compose", "distribution", 
"vote"]
+__all__ = [
+    "announce",
+    "candidate",
+    "committees",
+    "compose",
+    "distribution",
+    "docs",
+    "download",
+    "draft",
+    "vote",
+]
diff --git a/atr/routes/docs.py b/atr/get/docs.py
similarity index 92%
rename from atr/routes/docs.py
rename to atr/get/docs.py
index 5ee86c1..40e6047 100644
--- a/atr/routes/docs.py
+++ b/atr/get/docs.py
@@ -23,9 +23,10 @@ import aiofiles.os
 import markupsafe
 import quart
 
+import atr.blueprints.get as get
 import atr.config as config
-import atr.route as route
 import atr.template as template
+import atr.web as web
 
 
 class H1Parser(HTMLParser):
@@ -47,13 +48,13 @@ class H1Parser(HTMLParser):
             self.h1_content = data.strip()
 
 
[email protected]("/docs/")
-async def index(session: route.CommitterSession | None) -> str:
[email protected]("/docs/")
+async def index(session: web.Committer | None) -> str:
     return await _serve_docs_page("index")
 
 
[email protected]("/docs/<path:page>")
-async def page(session: route.CommitterSession | None, page: str) -> str:
[email protected]("/docs/<path:page>")
+async def page(session: web.Committer | None, page: str) -> str:
     return await _serve_docs_page(page)
 
 
diff --git a/atr/routes/download.py b/atr/get/download.py
similarity index 89%
rename from atr/routes/download.py
rename to atr/get/download.py
index d92c727..c5c7225 100644
--- a/atr/routes/download.py
+++ b/atr/get/download.py
@@ -15,8 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""download.py"""
-
 import pathlib
 from collections.abc import AsyncGenerator
 
@@ -27,11 +25,11 @@ import quart
 import werkzeug.wrappers.response as response
 import zipstream
 
+import atr.blueprints.get as get
 import atr.config as config
 import atr.db as db
 import atr.htm as htm
 import atr.models.sql as sql
-import atr.route as route
 import atr.routes.mapping as mapping
 import atr.routes.root as root
 import atr.template as template
@@ -39,10 +37,8 @@ import atr.util as util
 import atr.web as web
 
 
[email protected]("/download/all/<project_name>/<version_name>")
-async def all_selected(
-    session: route.CommitterSession, project_name: str, version_name: str
-) -> response.Response | str:
[email protected]("/download/all/<project_name>/<version_name>")
+async def all_selected(session: web.Committer, project_name: str, 
version_name: str) -> response.Response | str:
     """Display download commands for a release."""
     async with db.session() as data:
         release = await session.release(project_name=project_name, 
version_name=version_name, phase=None, data=data)
@@ -66,25 +62,25 @@ async def all_selected(
     )
 
 
[email protected]("/download/path/<project_name>/<version_name>/<path:file_path>")
[email protected]("/download/path/<project_name>/<version_name>/<path:file_path>")
 async def path(
-    session: route.CommitterSession | None, project_name: str, version_name: 
str, file_path: str
+    session: web.Committer | None, project_name: str, version_name: str, 
file_path: str
 ) -> response.Response | quart.Response:
     """Download a file or list a directory from a release in any phase."""
     return await _download_or_list(project_name, version_name, file_path)
 
 
[email protected]("/download/path/<project_name>/<version_name>/")
[email protected]("/download/path/<project_name>/<version_name>/")
 async def path_empty(
-    session: route.CommitterSession | None, project_name: str, version_name: 
str
+    session: web.Committer | None, project_name: str, version_name: str
 ) -> response.Response | quart.Response:
     """List files at the root of a release directory for download."""
     return await _download_or_list(project_name, version_name, ".")
 
 
[email protected]("/download/sh/<project_name>/<version_name>")
[email protected]("/download/sh/<project_name>/<version_name>")
 async def sh_selected(
-    session: route.CommitterSession | None, project_name: str, version_name: 
str
+    session: web.Committer | None, project_name: str, version_name: str
 ) -> response.Response | quart.Response:
     """Shell script to download a release."""
     conf = config.get()
@@ -99,9 +95,9 @@ async def sh_selected(
     return web.ShellResponse(content)
 
 
[email protected]("/download/urls/<project_name>/<version_name>")
[email protected]("/download/urls/<project_name>/<version_name>")
 async def urls_selected(
-    session: route.CommitterSession | None, project_name: str, version_name: 
str
+    session: web.Committer | None, project_name: str, version_name: str
 ) -> response.Response | quart.Response:
     try:
         async with db.session() as data:
@@ -116,9 +112,9 @@ async def urls_selected(
         return web.TextResponse(f"Internal server error: {e}", status=500)
 
 
[email protected]("/download/zip/<project_name>/<version_name>")
[email protected]("/download/zip/<project_name>/<version_name>")
 async def zip_selected(
-    session: route.CommitterSession, project_name: str, version_name: str
+    session: web.Committer, project_name: str, version_name: str
 ) -> response.Response | quart.wrappers.response.Response:
     try:
         release = await session.release(project_name=project_name, 
version_name=version_name, phase=None)
@@ -156,7 +152,7 @@ async def _download_or_list(project_name: str, 
version_name: str, file_path: str
     # Check that path is relative
     original_path = pathlib.Path(file_path)
     if (file_path != ".") and (not 
original_path.is_relative_to(original_path.anchor)):
-        raise route.FlashError("Path must be relative")
+        raise web.FlashError("Path must be relative")
 
     # We allow downloading files from any phase
     async with db.session() as data:
diff --git a/atr/get/draft.py b/atr/get/draft.py
new file mode 100644
index 0000000..0d2a513
--- /dev/null
+++ b/atr/get/draft.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.
+
+
+from __future__ import annotations
+
+import datetime
+import pathlib
+from typing import TYPE_CHECKING
+
+import aiofiles.os
+import asfquart.base as base
+
+import atr.blueprints.get as get
+import atr.forms as forms
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+if TYPE_CHECKING:
+    import werkzeug.wrappers.response as response
+
+
[email protected]("/draft/tools/<project_name>/<version_name>/<path:file_path>")
+async def tools(session: web.Committer, project_name: str, version_name: str, 
file_path: str) -> str:
+    """Show the tools for a specific file."""
+    await session.check_access(project_name)
+
+    release = await session.release(project_name, version_name)
+    full_path = str(util.release_directory(release) / file_path)
+
+    # Check that the file exists
+    if not await aiofiles.os.path.exists(full_path):
+        raise base.ASFQuartException("File does not exist", errorcode=404)
+
+    modified = int(await aiofiles.os.path.getmtime(full_path))
+    file_size = await aiofiles.os.path.getsize(full_path)
+
+    file_data = {
+        "filename": pathlib.Path(file_path).name,
+        "bytes_size": file_size,
+        "uploaded": datetime.datetime.fromtimestamp(modified, tz=datetime.UTC),
+    }
+
+    return await template.render(
+        "draft-tools.html",
+        asf_id=session.uid,
+        project_name=project_name,
+        version_name=version_name,
+        file_path=file_path,
+        file_data=file_data,
+        release=release,
+        format_file_size=util.format_file_size,
+        empty_form=await forms.Empty.create_form(),
+    )
+
+
+# TODO: Should we deprecate this and ensure compose covers it all?
+# If we did that, we'd lose the exhaustive use of the abstraction
[email protected]("/draft/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)
+
+    # Convert async generator to list
+    revision_number = release.latest_revision_number
+    file_stats = []
+    if revision_number is not None:
+        file_stats = [
+            stat
+            async for stat in util.content_list(util.get_unfinished_dir(), 
project_name, version_name, 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 candidate draft",
+        phase_key="draft",
+    )
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index ca6989d..9a396cb 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -20,8 +20,9 @@ from typing import Final, Literal
 import atr.post.announce as announce
 import atr.post.candidate as candidate
 import atr.post.distribution as distribution
+import atr.post.draft as draft
 import atr.post.vote as vote
 
 ROUTES_MODULE: Final[Literal[True]] = True
 
-__all__ = ["announce", "candidate", "distribution", "vote"]
+__all__ = ["announce", "candidate", "distribution", "draft", "vote"]
diff --git a/atr/routes/draft.py b/atr/post/draft.py
similarity index 71%
rename from atr/routes/draft.py
rename to atr/post/draft.py
index f1b6932..341f7e4 100644
--- a/atr/routes/draft.py
+++ b/atr/post/draft.py
@@ -15,29 +15,26 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""draft.py"""
-
 from __future__ import annotations
 
-import datetime
 import pathlib
-from typing import TYPE_CHECKING, TypeVar
+from typing import TYPE_CHECKING
 
 import aiofiles.os
 import aioshutil
 import asfquart.base as base
 import quart
 
+import atr.blueprints.post as post
 import atr.construct as construct
 import atr.forms as forms
 import atr.get.compose as compose
 import atr.log as log
 import atr.models.sql as sql
-import atr.route as route
 import atr.routes.root as root
 import atr.routes.upload as upload
+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
 
@@ -45,26 +42,6 @@ if TYPE_CHECKING:
     import werkzeug.wrappers.response as response
 
 
-T = TypeVar("T")
-
-
-class DeleteFileForm(forms.Typed):
-    """Form for deleting a file."""
-
-    file_path = forms.string("File path")
-    submit = forms.submit("Delete file")
-
-
-class DeleteForm(forms.Typed):
-    """Form for deleting a candidate draft."""
-
-    release_name = forms.hidden()
-    project_name = forms.hidden()
-    version_name = forms.hidden()
-    confirm_delete = forms.string("Confirmation", 
validators=forms.constant("DELETE"))
-    submit = forms.submit("Delete candidate draft")
-
-
 class VotePreviewForm(forms.Typed):
     body = forms.textarea("Body")
     # TODO: Validate the vote duration again?
@@ -73,10 +50,10 @@ class VotePreviewForm(forms.Typed):
     vote_duration = forms.integer("Vote duration")
 
 
[email protected]("/draft/delete", methods=["POST"])
-async def delete(session: route.CommitterSession) -> response.Response:
[email protected]("/draft/delete")
+async def delete(session: web.Committer) -> response.Response:
     """Delete a candidate draft and all its associated files."""
-    form = await DeleteForm.create_form(data=await quart.request.form)
+    form = await shared.draft.DeleteForm.create_form(data=await 
quart.request.form)
     if not await form.validate_on_submit():
         for _field, errors in form.errors.items():
             for error in errors:
@@ -117,12 +94,12 @@ async def delete(session: route.CommitterSession) -> 
response.Response:
     return await session.redirect(root.index, success="Candidate draft deleted 
successfully")
 
 
[email protected]("/draft/delete-file/<project_name>/<version_name>", 
methods=["POST"])
-async def delete_file(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
[email protected]("/draft/delete-file/<project_name>/<version_name>")
+async def delete_file(session: web.Committer, project_name: str, version_name: 
str) -> response.Response:
     """Delete a specific file from the release candidate, creating a new 
revision."""
     await session.check_access(project_name)
 
-    form = await DeleteFileForm.create_form(data=await quart.request.form)
+    form = await shared.draft.DeleteFileForm.create_form(data=await 
quart.request.form)
     if not await form.validate_on_submit():
         error_summary = []
         for key, value in form.errors.items():
@@ -152,8 +129,8 @@ async def delete_file(session: route.CommitterSession, 
project_name: str, versio
     )
 
 
[email protected]("/draft/fresh/<project_name>/<version_name>", 
methods=["POST"])
-async def fresh(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response:
[email protected]("/draft/fresh/<project_name>/<version_name>")
+async def fresh(session: web.Committer, project_name: str, version_name: str) 
-> response.Response:
     """Restart all checks for a whole release candidate draft."""
     # Admin only button, but it's okay if users find and use this manually
     await session.check_access(project_name)
@@ -178,10 +155,8 @@ async def fresh(session: route.CommitterSession, 
project_name: str, version_name
     )
 
 
[email protected]("/draft/hashgen/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
-async def hashgen(
-    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
-) -> response.Response:
[email protected]("/draft/hashgen/<project_name>/<version_name>/<path:file_path>")
+async def hashgen(session: web.Committer, project_name: str, version_name: 
str, file_path: str) -> response.Response:
     """Generate an sha256 or sha512 hash file for a candidate draft file, 
creating a new revision."""
     await session.check_access(project_name)
 
@@ -213,10 +188,8 @@ async def hashgen(
     )
 
 
[email protected]("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
-async def sbomgen(
-    session: route.CommitterSession, project_name: str, version_name: str, 
file_path: str
-) -> response.Response:
[email protected]("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>")
+async def sbomgen(session: web.Committer, project_name: str, version_name: 
str, file_path: str) -> response.Response:
     """Generate a CycloneDX SBOM file for a candidate draft file, creating a 
new revision."""
     await session.check_access(project_name)
 
@@ -244,14 +217,14 @@ async def sbomgen(
                 # Check that the source file exists in the new revision
                 if not await aiofiles.os.path.exists(path_in_new_revision):
                     log.error(f"Source file {rel_path} not found in new 
revision for SBOM generation.")
-                    raise route.FlashError("Source artifact file not found in 
the new revision.")
+                    raise web.FlashError("Source artifact file not found in 
the new revision.")
 
                 # Check that the SBOM file does not already exist in the new 
revision
                 if await aiofiles.os.path.exists(sbom_path_in_new_revision):
                     raise base.ASFQuartException("SBOM file already exists", 
errorcode=400)
 
             if creating.new is None:
-                raise route.FlashError("Internal error: New revision not 
found")
+                raise web.FlashError("Internal error: New revision not found")
 
             # Create and queue the task, using paths within the new revision
             sbom_task = await wacp.sbom.generate_cyclonedx(
@@ -272,8 +245,8 @@ async def sbomgen(
     )
 
 
[email protected]("/draft/svnload/<project_name>/<version_name>", 
methods=["POST"])
-async def svnload(session: route.CommitterSession, project_name: str, 
version_name: str) -> response.Response | str:
[email protected]("/draft/svnload/<project_name>/<version_name>")
+async def svnload(session: web.Committer, project_name: str, version_name: 
str) -> response.Response | str:
     """Import files from SVN into a draft."""
     await session.check_access(project_name)
 
@@ -316,76 +289,9 @@ async def svnload(session: route.CommitterSession, 
project_name: str, version_na
     )
 
 
[email protected]("/draft/tools/<project_name>/<version_name>/<path:file_path>")
-async def tools(session: route.CommitterSession, project_name: str, 
version_name: str, file_path: str) -> str:
-    """Show the tools for a specific file."""
-    await session.check_access(project_name)
-
-    release = await session.release(project_name, version_name)
-    full_path = str(util.release_directory(release) / file_path)
-
-    # Check that the file exists
-    if not await aiofiles.os.path.exists(full_path):
-        raise base.ASFQuartException("File does not exist", errorcode=404)
-
-    modified = int(await aiofiles.os.path.getmtime(full_path))
-    file_size = await aiofiles.os.path.getsize(full_path)
-
-    file_data = {
-        "filename": pathlib.Path(file_path).name,
-        "bytes_size": file_size,
-        "uploaded": datetime.datetime.fromtimestamp(modified, tz=datetime.UTC),
-    }
-
-    return await template.render(
-        "draft-tools.html",
-        asf_id=session.uid,
-        project_name=project_name,
-        version_name=version_name,
-        file_path=file_path,
-        file_data=file_data,
-        release=release,
-        format_file_size=util.format_file_size,
-        empty_form=await forms.Empty.create_form(),
-    )
-
-
-# TODO: Should we deprecate this and ensure compose covers it all?
-# If we did that, we'd lose the exhaustive use of the abstraction
[email protected]("/draft/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)
-
-    # Convert async generator to list
-    revision_number = release.latest_revision_number
-    file_stats = []
-    if revision_number is not None:
-        file_stats = [
-            stat
-            async for stat in util.content_list(util.get_unfinished_dir(), 
project_name, version_name, 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 candidate draft",
-        phase_key="draft",
-    )
-
-
[email protected]("/draft/vote/preview/<project_name>/<version_name>", 
methods=["POST"])
[email protected]("/draft/vote/preview/<project_name>/<version_name>")
 async def vote_preview(
-    session: route.CommitterSession, project_name: str, version_name: str
+    session: web.Committer, project_name: str, version_name: str
 ) -> quart.wrappers.response.Response | response.Response | str:
     """Show the vote email preview for a release."""
 
@@ -395,7 +301,7 @@ async def vote_preview(
 
     release = await session.release(project_name, version_name)
     if release.committee is None:
-        raise route.FlashError("Release has no associated committee")
+        raise web.FlashError("Release has no associated committee")
 
     form_body: str = util.unwrap(form.body.data)
     asfuid = session.uid
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index a0b4998..4ab4b88 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.docs as docs
-import atr.routes.download as download
-import atr.routes.draft as draft
 import atr.routes.file as file
 import atr.routes.finish as finish
 import atr.routes.ignores as ignores
@@ -39,9 +36,6 @@ import atr.routes.user as user
 import atr.routes.voting as voting
 
 __all__ = [
-    "docs",
-    "download",
-    "draft",
     "file",
     "finish",
     "ignores",
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 6cbda29..71e0565 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -174,7 +174,7 @@ async def add(session: route.CommitterSession) -> str:
             form = await AddOpenPGPKeyForm.create_form()
             forms.choices(form.selected_committees, committee_choices)
 
-        except route.FlashError as e:
+        except (route.FlashError, web.FlashError) as e:
             log.warning("FlashError adding OpenPGP key: %s", e)
             await quart.flash(str(e), "error")
         except Exception as e:
diff --git a/atr/routes/start.py b/atr/routes/start.py
index acae711..a1c9917 100644
--- a/atr/routes/start.py
+++ b/atr/routes/start.py
@@ -28,6 +28,7 @@ import atr.models.sql as sql
 import atr.route as route
 import atr.storage as storage
 import atr.template as template
+import atr.web as web
 
 
 class StartReleaseForm(forms.Typed):
@@ -71,7 +72,7 @@ async def selected(session: route.CommitterSession, 
project_name: str) -> respon
                 version_name=new_release.version,
                 success="Release candidate draft created successfully",
             )
-        except (route.FlashError, base.ASFQuartException) as e:
+        except (route.FlashError, web.FlashError, base.ASFQuartException) as e:
             # Flash the error and let the code fall through to render the 
template below
             await quart.flash(str(e), "error")
 
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index 16dd241..352c86b 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -25,9 +25,9 @@ import atr.db.interaction as interaction
 import atr.forms as forms
 import atr.models.results as results
 import atr.models.sql as sql
-import atr.routes.draft as draft
 import atr.shared.announce as announce
 import atr.shared.distribution as distribution
+import atr.shared.draft as draft
 import atr.shared.vote as vote
 import atr.storage as storage
 import atr.template as template
diff --git a/atr/get/__init__.py b/atr/shared/draft.py
similarity index 61%
copy from atr/get/__init__.py
copy to atr/shared/draft.py
index 352ab48..150cc6e 100644
--- a/atr/get/__init__.py
+++ b/atr/shared/draft.py
@@ -15,15 +15,21 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Final, Literal
+import atr.forms as forms
 
-import atr.get.announce as announce
-import atr.get.candidate as candidate
-import atr.get.committees as committees
-import atr.get.compose as compose
-import atr.get.distribution as distribution
-import atr.get.vote as vote
 
-ROUTES_MODULE: Final[Literal[True]] = True
+class DeleteFileForm(forms.Typed):
+    """Form for deleting a file."""
 
-__all__ = ["announce", "candidate", "committees", "compose", "distribution", 
"vote"]
+    file_path = forms.string("File path")
+    submit = forms.submit("Delete file")
+
+
+class DeleteForm(forms.Typed):
+    """Form for deleting a candidate draft."""
+
+    release_name = forms.hidden()
+    project_name = forms.hidden()
+    version_name = forms.hidden()
+    confirm_delete = forms.string("Confirmation", 
validators=forms.constant("DELETE"))
+    submit = forms.submit("Delete candidate draft")
diff --git a/atr/templates/check-selected-path-table.html 
b/atr/templates/check-selected-path-table.html
index 5470f1d..b67368b 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -93,7 +93,7 @@
                         title="Show more actions for {{ path }}"
                         onclick="this.innerHTML = (this.innerHTML.trim() === 
'More') ? 'Less' : 'More';">More</button>
               {% elif phase == "release_candidate" %}
-                <a href="{{ as_url(routes.download.path, 
project_name=release.project.name, version_name=release.version, 
file_path=path) }}"
+                <a href="{{ as_url(get.download.path, 
project_name=release.project.name, version_name=release.version, 
file_path=path) }}"
                    title="Download file {{ path }}"
                    class="btn btn-sm btn-outline-secondary">Download</a>
               {% endif %}
@@ -109,10 +109,10 @@
                   <div class="btn-group btn-group-sm"
                        role="group"
                        aria-label="More file actions for {{ path }}">
-                    <a href="{{ as_url(routes.download.path, 
project_name=release.project.name, version_name=release.version, 
file_path=path) }}"
+                    <a href="{{ as_url(get.download.path, 
project_name=release.project.name, version_name=release.version, 
file_path=path) }}"
                        title="Download file {{ path }}"
                        class="btn btn-outline-secondary">Download</a>
-                    <a href="{{ as_url(routes.draft.tools, 
project_name=project_name, version_name=version_name, file_path=path) }}"
+                    <a href="{{ as_url(get.draft.tools, 
project_name=project_name, version_name=version_name, file_path=path) }}"
                        title="Tools for file {{ path }}"
                        class="btn btn-outline-secondary">Tools</a>
                     <button class="btn btn-outline-danger"
@@ -120,7 +120,7 @@
                             data-bs-target="#delete-{{ row_id }}"
                             title="Delete file {{ path }}">Delete</button>
                   </div>
-                  {{ dialog.delete_modal(path, "Delete file", "file, and any 
associated metadata files", as_url(routes.draft.delete_file, 
project_name=project_name, version_name=version_name) , delete_file_form, 
"file_path") }}
+                  {{ dialog.delete_modal(path, "Delete file", "file, and any 
associated metadata files", as_url(post.draft.delete_file, 
project_name=project_name, version_name=version_name) , delete_file_form, 
"file_path") }}
                 </div>
               </div>
             </td>
diff --git a/atr/templates/check-selected-release-info.html 
b/atr/templates/check-selected-release-info.html
index 1e84a78..643d441 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -45,7 +45,7 @@
            title="Upload files to this draft"
            class="btn btn-primary"><i class="bi bi-upload me-1"></i> Upload 
files</a>
 
-        <a href="{{ as_url(routes.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
+        <a href="{{ as_url(get.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
            title="Download {%- if has_files -%}files{%- else -%}links{%- endif 
-%}"
            class="btn btn-primary"><i class="bi bi-download me-1"></i> Download
           {% if has_files %}
@@ -73,7 +73,7 @@
         {% endif %}
         <a href="#more-actions" class="btn btn-outline-secondary"><i class="bi 
bi-gear me-1"></i> More actions</a>
       {% elif phase == "release_candidate" %}
-        <a href="{{ as_url(routes.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
+        <a href="{{ as_url(get.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(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>
diff --git a/atr/templates/check-selected.html 
b/atr/templates/check-selected.html
index e2ac6ab..37567ee 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -171,7 +171,7 @@
     </div>
     <div class="mb-3">
       <form method="post"
-            action="{{ as_url(routes.draft.fresh, 
project_name=release.project.name, version_name=release.version) }}"
+            action="{{ as_url(post.draft.fresh, 
project_name=release.project.name, version_name=release.version) }}"
             class="mb-0">
         {{ empty_form.hidden_tag() }}
 
@@ -182,7 +182,7 @@
     <h3 id="delete-draft" class="mt-4">Delete this draft</h3>
     <div>
       <form method="post"
-            action="{{ as_url(routes.draft.delete, 
project_name=release.project.name, version_name=release.version) }}"
+            action="{{ as_url(post.draft.delete, 
project_name=release.project.name, version_name=release.version) }}"
             class="mb-0">
         {{ delete_form.hidden_tag() }}
 
diff --git a/atr/templates/download-all.html b/atr/templates/download-all.html
index 13346fa..f8f1976 100644
--- a/atr/templates/download-all.html
+++ b/atr/templates/download-all.html
@@ -67,7 +67,7 @@
     The archive is generated on the fly, which may take a while for very large 
releases.
   </p>
   <p>
-    <a href="{{ as_url(routes.download.zip_selected, 
project_name=release.project.name, version_name=release.version) }}"
+    <a href="{{ as_url(get.download.zip_selected, 
project_name=release.project.name, version_name=release.version) }}"
        class="btn btn-primary btn-lg">
       <i class="bi bi-file-earmark-zip me-2"></i>Download {{ release.name 
}}.zip
     </a>
@@ -92,17 +92,17 @@
 
   <h3 id="download-browser" class="mt-4">Using your browser</h3>
   <p>
-    You can download the files one by one using your browser from the <a 
href="{{ as_url(routes.download.path_empty, project_name=release.project.name, 
version_name=release.version) }}">download folder</a>. Clicking a link to any 
file will download it, as it is served as <code>application/octet-stream</code>.
+    You can download the files one by one using your browser from the <a 
href="{{ as_url(get.download.path_empty, project_name=release.project.name, 
version_name=release.version) }}">download folder</a>. Clicking a link to any 
file will download it, as it is served as <code>application/octet-stream</code>.
   </p>
 
   <h3 id="download-curl" class="mt-4">Using curl</h3>
   <p>You can download all of the files in this release using curl with the 
following command:</p>
   <!-- TODO: Add a button to copy the command to the clipboard -->
   <pre class="bg-light border rounded p-3 mb-3">
-curl{% if server_domain == "localhost.apache.org" %} --insecure{% endif %} 
-fsS https://{{ server_host }}{{ as_url(routes.download.sh_selected, 
project_name=release.project.name, version_name=release.version) }} |{% if 
server_domain == "localhost.apache.org" %} CURL_EXTRA=--insecure{% endif %} sh
+curl{% if server_domain == "localhost.apache.org" %} --insecure{% endif %} 
-fsS https://{{ server_host }}{{ as_url(get.download.sh_selected, 
project_name=release.project.name, version_name=release.version) }} |{% if 
server_domain == "localhost.apache.org" %} CURL_EXTRA=--insecure{% endif %} sh
 </pre>
   <p>
-    This downloads the files into the <em>current directory</em>. Ensure that 
you create a new empty directory, and change to it, before running the command. 
The script requires curl and a POSIX compliant version of sh. It works by 
downloading a POSIX complaint shell script straight into your shell. You can of 
course <a href="{{ as_url(routes.download.sh_selected, 
project_name=release.project.name, version_name=release.version) }}">download 
the script</a> and audit it before running it.
+    This downloads the files into the <em>current directory</em>. Ensure that 
you create a new empty directory, and change to it, before running the command. 
The script requires curl and a POSIX compliant version of sh. It works by 
downloading a POSIX complaint shell script straight into your shell. You can of 
course <a href="{{ as_url(get.download.sh_selected, 
project_name=release.project.name, version_name=release.version) }}">download 
the script</a> and audit it before running it.
   </p>
 
   <h3 id="download-rsync" class="mt-4">Using rsync</h3>
@@ -116,7 +116,7 @@ rsync -av -e 'ssh -p 2222' {{ asf_id }}@{{ server_domain 
}}:/{{ release.project.
   <h3 id="download-wget" class="mt-4">Using wget</h3>
   <p>You can download all of the files in this release using wget with the 
following command:</p>
   <pre class="bg-light border rounded p-3 mb-3">
-wget -r -np -nH --cut-dirs=4 --default-page=.index.html{% if server_domain == 
"localhost.apache.org" %} --no-check-certificate{% endif %} https://{{ 
server_host }}{{ as_url(routes.download.path_empty, 
project_name=release.project.name, version_name=release.version) }}
+wget -r -np -nH --cut-dirs=4 --default-page=.index.html{% if server_domain == 
"localhost.apache.org" %} --no-check-certificate{% endif %} https://{{ 
server_host }}{{ as_url(get.download.path_empty, 
project_name=release.project.name, version_name=release.version) }}
 </pre>
   <p>
     This downloads the files into the <em>current directory</em>. Ensure that 
you create a new empty directory, and change to it, before running the command.
diff --git a/atr/templates/draft-tools.html b/atr/templates/draft-tools.html
index e474d6e..a7607da 100644
--- a/atr/templates/draft-tools.html
+++ b/atr/templates/draft-tools.html
@@ -35,14 +35,14 @@
   </div>
   <div class="d-flex gap-2 mb-4">
     <form method="post"
-          action="{{ as_url(routes.draft.hashgen, project_name=project_name, 
version_name=version_name, file_path=file_path) }}">
+          action="{{ as_url(post.draft.hashgen, project_name=project_name, 
version_name=version_name, file_path=file_path) }}">
       {{ empty_form.hidden_tag() }}
 
       <input type="hidden" name="hash_type" value="sha256" />
       <button type="submit" class="btn btn-outline-secondary">Generate 
SHA256</button>
     </form>
     <form method="post"
-          action="{{ as_url(routes.draft.hashgen, project_name=project_name, 
version_name=version_name, file_path=file_path) }}">
+          action="{{ as_url(post.draft.hashgen, project_name=project_name, 
version_name=version_name, file_path=file_path) }}">
       {{ empty_form.hidden_tag() }}
 
       <input type="hidden" name="hash_type" value="sha512" />
@@ -55,7 +55,7 @@
     <p>NOTE: This functionality is currently not available.</p>
     <p>Generate a CycloneDX Software Bill of Materials (SBOM) file for this 
artifact.</p>
     <form method="post"
-          action="{{ as_url(routes.draft.sbomgen, project_name=project_name, 
version_name=version_name, file_path=file_path) }}">
+          action="{{ as_url(post.draft.sbomgen, project_name=project_name, 
version_name=version_name, file_path=file_path) }}">
       {{ empty_form.hidden_tag() }}
 
       <button type="submit" class="btn btn-outline-secondary">Generate 
CycloneDX SBOM (.cdx.json)</button>
diff --git a/atr/templates/file-selected-path.html 
b/atr/templates/file-selected-path.html
index 5cf8dcd..d3cee26 100644
--- a/atr/templates/file-selected-path.html
+++ b/atr/templates/file-selected-path.html
@@ -11,7 +11,7 @@
 {% block content %}
   {# Generate back link based on phase_key #}
   {% if phase_key == "draft" %}
-    {% set back_url = as_url(routes.draft.view, 
project_name=release.project.name, version_name=release.version) %}
+    {% set back_url = as_url(get.draft.view, 
project_name=release.project.name, version_name=release.version) %}
   {% 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" %}
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
index bea1604..3722886 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -69,7 +69,7 @@
       </div>
       <div>
         <a title="Download all files"
-           href="{{ as_url(routes.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
+           href="{{ as_url(get.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
            class="btn btn-primary me-2">
           <i class="bi bi-download"></i>
           Download all files
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index 2967c55..13a95e6 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -121,7 +121,7 @@
         </li>
         <li>
           <i class="bi bi-book"></i>
-          <a href="{{ as_url(routes.docs.index) }}">Documentation</a>
+          <a href="{{ as_url(get.docs.index) }}">Documentation</a>
         </li>
         <li>
           <i class="bi bi-book"></i>
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index c745561..12455c8 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -413,7 +413,7 @@
       <h2>Draft candidate releases</h2>
       <div class="d-flex flex-wrap gap-2 mb-4">
         {% for draft in candidate_drafts %}
-          <a href="{{ as_url(routes.draft.view, project_name=project.name, 
version_name=draft.version) }}"
+          <a href="{{ as_url(get.draft.view, project_name=project.name, 
version_name=draft.version) }}"
              class="btn btn-sm btn-outline-secondary py-2 px-3"
              title="View draft {{ project.name }} {{ draft.version }}">
             {{ project.name }} {{ draft.version }}
diff --git a/atr/templates/releases-finished.html 
b/atr/templates/releases-finished.html
index ba64e4c..606faa1 100644
--- a/atr/templates/releases-finished.html
+++ b/atr/templates/releases-finished.html
@@ -25,7 +25,7 @@
               <strong class="card-title fs-5">{{ release.version }}</strong>
               <p class="card-text text-muted">Released on {{ 
format_datetime(release.created) }}</p>
               <div class="mt-auto">
-                <a href="{{ as_url(routes.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
+                <a href="{{ as_url(get.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
                    class="btn btn-outline-primary w-100 mb-2">
                   <i class="bi bi-download me-1"></i> Download files
                 </a>
diff --git a/atr/templates/upload-selected.html 
b/atr/templates/upload-selected.html
index f5ff081..cc6cfd2 100644
--- a/atr/templates/upload-selected.html
+++ b/atr/templates/upload-selected.html
@@ -75,7 +75,7 @@
   {{ forms.errors_summary(svn_form) }}
   <div class="row">
     <div class="col-md-8 w-100">
-      <form action="{{ as_url(routes.draft.svnload, project_name=project_name, 
version_name=version_name) }}"
+      <form action="{{ as_url(post.draft.svnload, project_name=project_name, 
version_name=version_name) }}"
             method="post"
             novalidate
             class="atr-canary py-4 px-5">
diff --git a/atr/templates/voting-selected-revision.html 
b/atr/templates/voting-selected-revision.html
index c3b5686..76e59f4 100644
--- a/atr/templates/voting-selected-revision.html
+++ b/atr/templates/voting-selected-revision.html
@@ -178,7 +178,7 @@
               return;
           }
 
-          const previewUrl = "{{ as_url(routes.draft.vote_preview, 
project_name=release.project.name, version_name=release.version) }}";
+          const previewUrl = "{{ as_url(post.draft.vote_preview, 
project_name=release.project.name, version_name=release.version) }}";
           const csrfTokenInput = 
voteForm.querySelector('input[name="csrf_token"]');
 
           if (!previewUrl || !csrfTokenInput) {
diff --git a/atr/web.py b/atr/web.py
index 32ae7e7..05f05cb 100644
--- a/atr/web.py
+++ b/atr/web.py
@@ -179,6 +179,10 @@ class ElementResponse(quart.Response):
         super().__init__(str(element), status=status, mimetype="text/html")
 
 
+class FlashError(RuntimeError):
+    """Error that triggers a flash message."""
+
+
 class HeaderValue:
     # TODO: There does not appear to be a general HTTP header construction 
package in Python
     # The existence of one would help us and others to adhere to the HTTP 
component of ASVS v5 1.2.1


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


Reply via email to