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 2964210  Make the draft forms more type safe
2964210 is described below

commit 2964210ed26bb87fa3b48aabb49b3f08a5436e3e
Author: Andrew Musselman <[email protected]>
AuthorDate: Tue Nov 11 13:07:03 2025 -0800

    Make the draft forms more type safe
---
 atr/form.py                                       |  2 +-
 atr/get/draft.py                                  | 37 ++++++++++-
 atr/post/draft.py                                 | 79 +++++++----------------
 atr/shared/__init__.py                            | 48 +++++++++++---
 atr/shared/draft.py                               | 19 ++----
 atr/templates/check-selected-candidate-forms.html |  2 +-
 atr/templates/check-selected-path-table.html      | 26 +++-----
 atr/templates/check-selected.html                 | 34 +---------
 atr/templates/draft-tools.html                    | 23 +------
 9 files changed, 121 insertions(+), 149 deletions(-)

diff --git a/atr/form.py b/atr/form.py
index 8bdca3b..ac155dd 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -45,7 +45,7 @@ if TYPE_CHECKING:
 DISCRIMINATOR_NAME: Final[str] = "variant"
 DISCRIMINATOR: Final[Any] = schema.discriminator(DISCRIMINATOR_NAME)
 
-_CONFIRM_PATTERN = re.compile(r"^[A-Za-z0-9 .,!?-]+$")
+_CONFIRM_PATTERN = re.compile(r"^[A-Za-z0-9 _.,!?-]+$")
 
 
 class Form(schema.Form):
diff --git a/atr/get/draft.py b/atr/get/draft.py
index 2383972..4b4f6ec 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -24,7 +24,9 @@ import aiofiles.os
 import asfquart.base as base
 
 import atr.blueprints.get as get
-import atr.forms as forms
+import atr.form as form
+import atr.post as post
+import atr.shared as shared
 import atr.template as template
 import atr.util as util
 import atr.web as web
@@ -51,6 +53,35 @@ async def tools(session: web.Committer, project_name: str, 
version_name: str, fi
         "uploaded": datetime.datetime.fromtimestamp(modified, tz=datetime.UTC),
     }
 
+    hashgen_action = util.as_url(
+        post.draft.hashgen, project_name=project_name, 
version_name=version_name, file_path=file_path
+    )
+    sha256_form = form.render(
+        model_cls=shared.draft.HashGen,
+        action=hashgen_action,
+        submit_label="Generate SHA256",
+        submit_classes="btn-outline-secondary",
+        defaults={"hash_type": "sha256"},
+        empty=True,
+    )
+    sha512_form = form.render(
+        model_cls=shared.draft.HashGen,
+        action=hashgen_action,
+        submit_label="Generate SHA512",
+        submit_classes="btn-outline-secondary",
+        defaults={"hash_type": "sha512"},
+        empty=True,
+    )
+    sbom_form = form.render(
+        model_cls=form.Empty,
+        action=util.as_url(
+            post.draft.sbomgen, project_name=project_name, 
version_name=version_name, file_path=file_path
+        ),
+        submit_label="Generate CycloneDX SBOM (.cdx.json)",
+        submit_classes="btn-outline-secondary",
+        empty=True,
+    )
+
     return await template.render(
         "draft-tools.html",
         asf_id=session.uid,
@@ -60,5 +91,7 @@ async def tools(session: web.Committer, project_name: str, 
version_name: str, fi
         file_data=file_data,
         release=release,
         format_file_size=util.format_file_size,
-        empty_form=await forms.Empty.create_form(),
+        sha256_form=sha256_form,
+        sha512_form=sha512_form,
+        sbom_form=sbom_form,
     )
diff --git a/atr/post/draft.py b/atr/post/draft.py
index f2df389..770ef39 100644
--- a/atr/post/draft.py
+++ b/atr/post/draft.py
@@ -27,7 +27,7 @@ import quart
 
 import atr.blueprints.post as post
 import atr.construct as construct
-import atr.forms as forms
+import atr.form as form
 import atr.get as get
 import atr.log as log
 import atr.models.sql as sql
@@ -37,37 +37,16 @@ import atr.util as util
 import atr.web as web
 
 
-class VotePreviewForm(forms.Typed):
-    body = forms.textarea("Body")
-    # TODO: Validate the vote duration again?
-    # Probably not necessary in a preview
-    # Note that tasks/vote.py does not use this form
-    vote_duration = forms.integer("Vote duration")
+class VotePreviewForm(form.Form):
+    body: str = form.label("Body", widget=form.Widget.TEXTAREA)
+    # Note: this does not provide any vote duration validation; this simply 
displays a preview to the user
+    vote_duration: form.Int = form.label("Vote duration")
 
 
[email protected]("/draft/delete")
-async def delete(session: web.Committer) -> web.WerkzeugResponse:
[email protected]("/compose/<project_name>/<version_name>")
[email protected]()
+async def delete(session: web.Committer, project_name: str, version_name: str) 
-> web.WerkzeugResponse:
     """Delete a candidate draft and all its associated files."""
-    import atr.get as get
-
-    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:
-                await quart.flash(f"{error}", "error")
-        return await session.redirect(get.root.index)
-
-    release_name = form.release_name.data
-    if not release_name:
-        return await session.redirect(get.root.index, error="Missing required 
parameters")
-
-    project_name = form.project_name.data
-    if not project_name:
-        return await session.redirect(get.root.index, error="Missing required 
parameters")
-
-    version_name = form.version_name.data
-    if not version_name:
-        return await session.redirect(get.root.index, error="Missing required 
parameters")
 
     await session.check_access(project_name)
 
@@ -92,19 +71,14 @@ async def delete(session: web.Committer) -> 
web.WerkzeugResponse:
 
 
 @post.committer("/draft/delete-file/<project_name>/<version_name>")
-async def delete_file(session: web.Committer, project_name: str, version_name: 
str) -> web.WerkzeugResponse:
[email protected](shared.draft.DeleteFileForm)
+async def delete_file(
+    session: web.Committer, delete_file_form: shared.draft.DeleteFileForm, 
project_name: str, version_name: str
+) -> web.WerkzeugResponse:
     """Delete a specific file from the release candidate, creating a new 
revision."""
     await session.check_access(project_name)
 
-    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():
-            error_summary.append(f"{key}: {value}")
-        await quart.flash("; ".join(error_summary), "error")
-        return await session.redirect(get.compose.selected, 
project_name=project_name, version_name=version_name)
-
-    rel_path_to_delete = pathlib.Path(str(form.file_path.data))
+    rel_path_to_delete = pathlib.Path(str(delete_file_form.file_path))
 
     try:
         async with storage.write(session) as write:
@@ -127,12 +101,12 @@ async def delete_file(session: web.Committer, 
project_name: str, version_name: s
 
 
 @post.committer("/draft/fresh/<project_name>/<version_name>")
[email protected]()
 async def fresh(session: web.Committer, project_name: str, version_name: str) 
-> web.WerkzeugResponse:
     """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)
 
-    await util.validate_empty_form()
     # Restart checks by creating a new identical draft revision
     # This doesn't make sense unless the checks themselves have been updated
     # Therefore we only show the button for this to admins
@@ -153,15 +127,14 @@ async def fresh(session: web.Committer, project_name: 
str, version_name: str) ->
 
 
 
@post.committer("/draft/hashgen/<project_name>/<version_name>/<path:file_path>")
-async def hashgen(session: web.Committer, project_name: str, version_name: 
str, file_path: str) -> web.WerkzeugResponse:
[email protected](shared.draft.HashGen)
+async def hashgen(
+    session: web.Committer, hashgen_form: shared.draft.HashGen, project_name: 
str, version_name: str, file_path: str
+) -> web.WerkzeugResponse:
     """Generate an sha256 or sha512 hash file for a candidate draft file, 
creating a new revision."""
     await session.check_access(project_name)
 
-    # Get the hash type from the form data
-    # TODO: This is not truly empty, so make a form object for this
-    await util.validate_empty_form()
-    form = await quart.request.form
-    hash_type = form.get("hash_type")
+    hash_type = hashgen_form.hash_type
     if hash_type not in {"sha256", "sha512"}:
         raise base.ASFQuartException(f"Invalid hash type '{hash_type}'. 
Supported types: sha256, sha512", errorcode=400)
 
@@ -186,11 +159,11 @@ async def hashgen(session: web.Committer, project_name: 
str, version_name: str,
 
 
 
@post.committer("/draft/sbomgen/<project_name>/<version_name>/<path:file_path>")
[email protected]()
 async def sbomgen(session: web.Committer, project_name: str, version_name: 
str, file_path: str) -> web.WerkzeugResponse:
     """Generate a CycloneDX SBOM file for a candidate draft file, creating a 
new revision."""
     await session.check_access(project_name)
 
-    await util.validate_empty_form()
     rel_path = pathlib.Path(file_path)
 
     # Check that the file is a .tar.gz archive before creating a revision
@@ -248,25 +221,21 @@ async def sbomgen(session: web.Committer, project_name: 
str, version_name: str,
 
 
 @post.committer("/draft/vote/preview/<project_name>/<version_name>")
[email protected](VotePreviewForm)
 async def vote_preview(
-    session: web.Committer, project_name: str, version_name: str
+    session: web.Committer, vote_preview_form: VotePreviewForm, project_name: 
str, version_name: str
 ) -> web.QuartResponse | web.WerkzeugResponse | str:
     """Show the vote email preview for a release."""
-    import atr.get as get
-
-    form = await VotePreviewForm.create_form(data=await quart.request.form)
-    if not await form.validate_on_submit():
-        return await session.redirect(get.root.index, error="Invalid form 
data")
 
     release = await session.release(project_name, version_name)
     if release.committee is None:
         raise web.FlashError("Release has no associated committee")
 
-    form_body: str = util.unwrap(form.body.data)
+    form_body: str = vote_preview_form.body
     asfuid = session.uid
     project_name = release.project.name
     version_name = release.version
-    vote_duration: int = util.unwrap(form.vote_duration.data)
+    vote_duration: int = vote_preview_form.vote_duration
     vote_end = datetime.datetime.now(datetime.UTC) + 
datetime.timedelta(hours=vote_duration)
     vote_end_str = vote_end.strftime("%Y-%m-%d %H:%M:%S UTC")
 
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index ec65f53..3535a0b 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -21,10 +21,12 @@ import wtforms
 
 import atr.db as db
 import atr.db.interaction as interaction
-import atr.forms as forms
+import atr.form as form
+import atr.get as get
 import atr.htm as htm
 import atr.models.results as results
 import atr.models.sql as sql
+import atr.post as post
 import atr.shared.announce as announce
 import atr.shared.distribution as distribution
 import atr.shared.draft as draft
@@ -82,7 +84,7 @@ async def check(
     session: web.Committer | None,
     release: sql.Release,
     task_mid: str | None = None,
-    form: htm.Element | None = None,
+    vote_form: htm.Element | None = None,
     resolve_form: wtforms.Form | None = None,
     archive_url: str | None = None,
     vote_task: sql.Task | None = None,
@@ -126,11 +128,39 @@ async def check(
             revision_editor = None
             revision_timestamp = None
 
-    delete_draft_form = await draft.DeleteForm.create_form(
-        data={"release_name": release.name, "project_name": 
release.project.name, "version_name": release.version}
+    delete_form = form.render(
+        model_cls=form.Empty,
+        action=util.as_url(get.compose.selected, 
project_name=release.project.name, version_name=release.version),
+        submit_label="Delete this draft",
+        submit_classes="btn btn-danger",
+        empty=True,
+        confirm="Are you sure you want to delete this draft? This cannot be 
undone.",
     )
-    delete_file_form = await draft.DeleteFileForm.create_form()
-    empty_form = await forms.Empty.create_form()
+
+    delete_file_forms: dict[str, htm.Element] = {}
+    for path in paths:
+        delete_file_forms[str(path)] = form.render(
+            model_cls=draft.DeleteFileForm,
+            action=util.as_url(post.draft.delete_file, 
project_name=release.project.name, version_name=release.version),
+            form_classes=".d-inline-block.m-0",
+            submit_classes="btn-sm btn-outline-danger",
+            submit_label="Delete",
+            empty=True,
+            defaults={"file_path": str(path)},
+            confirm=(
+                f"Are you sure you want to delete {path}? "
+                f"This will also delete any associated metadata files. "
+                f"This cannot be undone."
+            ),
+        )
+
+    empty_form = form.render(
+        model_cls=form.Empty,
+        action=util.as_url(post.draft.fresh, 
project_name=release.project.name, version_name=release.version),
+        submit_label="Restart all checks",
+        submit_classes="btn btn-primary",
+    )
+
     vote_task_warnings = _warnings_from_vote_result(vote_task)
     has_files = await util.has_files(release)
 
@@ -149,8 +179,8 @@ async def check(
         revision_time=revision_timestamp,
         revision_number=revision_number,
         ongoing_tasks_count=ongoing_tasks_count,
-        delete_form=delete_draft_form,
-        delete_file_form=delete_file_form,
+        delete_form=delete_form,
+        delete_file_forms=delete_file_forms,
         asf_id=asf_id,
         server_domain=server_domain,
         server_host=server_host,
@@ -158,7 +188,7 @@ async def check(
         format_datetime=util.format_datetime,
         models=sql,
         task_mid=task_mid,
-        form=form,
+        vote_form=vote_form,
         vote_task=vote_task,
         archive_url=archive_url,
         vote_task_warnings=vote_task_warnings,
diff --git a/atr/shared/draft.py b/atr/shared/draft.py
index 150cc6e..ec655d3 100644
--- a/atr/shared/draft.py
+++ b/atr/shared/draft.py
@@ -15,21 +15,12 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import atr.forms as forms
+import atr.form as form
 
 
-class DeleteFileForm(forms.Typed):
-    """Form for deleting a file."""
+class DeleteFileForm(form.Form):
+    file_path: str = form.label("File path", widget=form.Widget.HIDDEN)
 
-    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 HashGen(form.Form):
+    hash_type: str = form.label("Hash type", widget=form.Widget.HIDDEN)
diff --git a/atr/templates/check-selected-candidate-forms.html 
b/atr/templates/check-selected-candidate-forms.html
index cdf7b60..95919a2 100644
--- a/atr/templates/check-selected-candidate-forms.html
+++ b/atr/templates/check-selected-candidate-forms.html
@@ -6,5 +6,5 @@
 {% if can_vote and form %}
   <h2>Cast your vote</h2>
 
-  {{ form }}
+  {{ vote_form }}
 {% endif %}
diff --git a/atr/templates/check-selected-path-table.html 
b/atr/templates/check-selected-path-table.html
index c8020dc..d23291a 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -105,22 +105,16 @@
           <tr class="{{ row_bg_class }}">
             <td colspan="3" class="p-0 border-0">
               <div class="collapse px-3 py-2" id="actions-{{ row_id }}">
-                <div class="d-flex justify-content-end">
-                  <div class="btn-group btn-group-sm"
-                       role="group"
-                       aria-label="More file actions for {{ 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(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"
-                            data-bs-toggle="modal"
-                            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(post.draft.delete_file, 
project_name=project_name, version_name=version_name) , delete_file_form, 
"file_path") }}
+                <div class="d-flex justify-content-end align-items-center 
gap-2"
+                     role="group"
+                     aria-label="More file actions for {{ 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>
+                  <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-sm btn-outline-secondary">Tools</a>
+                  {{ delete_file_forms[path|string] }}
                 </div>
               </div>
             </td>
diff --git a/atr/templates/check-selected.html 
b/atr/templates/check-selected.html
index 49959dd..5884761 100644
--- a/atr/templates/check-selected.html
+++ b/atr/templates/check-selected.html
@@ -185,39 +185,11 @@
     <div class="mb-2">
       <p>The following form is for debugging purposes only. It will create a 
new revision.</p>
     </div>
-    <div class="mb-3">
-      <form method="post"
-            action="{{ as_url(post.draft.fresh, 
project_name=release.project.name, version_name=release.version) }}"
-            class="mb-0">
-        {{ empty_form.hidden_tag() }}
-
-        <button type="submit" class="btn btn-primary">Restart all 
checks</button>
-      </form>
-    </div>
+    <div class="mb-3">{{ empty_form|safe }}</div>
 
     <h3 id="delete-draft" class="mt-4">Delete this draft</h3>
-    <div>
-      <form method="post"
-            action="{{ as_url(post.draft.delete, 
project_name=release.project.name, version_name=release.version) }}"
-            class="mb-0">
-        {{ delete_form.hidden_tag() }}
-
-        <div class="mb-3">
-          <label for="confirm_delete_draft" class="form-label">
-            Type <strong>DELETE</strong> to confirm:
-          </label>
-          <input class="form-control mt-2"
-                 id="confirm_delete_draft"
-                 name="confirm_delete"
-                 placeholder="DELETE"
-                 required=""
-                 type="text"
-                 value=""
-                 onkeyup="updateDeleteButton(this, 'delete-draft-button')" />
-        </div>
-        {{ delete_form.submit(class_="btn btn-danger", 
id_="delete-draft-button", disabled=True) }}
-      </form>
-    </div>
+    <p>Permanently delete this release candidate draft and all associated 
files. This action cannot be undone.</p>
+    <div id="delete-draft-form">{{ delete_form|safe }}</div>
   {% endif %}
   {% if phase == "release_candidate" %}
     {% include "check-selected-candidate-forms.html" %}
diff --git a/atr/templates/draft-tools.html b/atr/templates/draft-tools.html
index ad645f9..1cb1fb8 100644
--- a/atr/templates/draft-tools.html
+++ b/atr/templates/draft-tools.html
@@ -32,31 +32,14 @@
     Please select SHA512 unless you have a specific reason to use SHA256.
   </div>
   <div class="d-flex gap-2 mb-4">
-    <form method="post"
-          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(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" />
-      <button type="submit" class="btn btn-outline-secondary">Generate 
SHA512</button>
-    </form>
+    {{ sha256_form|safe }}
+    {{ sha512_form|safe }}
   </div>
 
   {% if file_path.endswith(".tar.gz") and 
is_viewing_as_admin_fn(current_user.uid) %}
     <h3>Generate SBOM</h3>
     <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(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>
-    </form>
+    {{ sbom_form|safe }}
   {% endif %}
 {% endblock content %}


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

Reply via email to