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]