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 fc3f43b Make admin forms to delete keys and releases more type safe
fc3f43b is described below
commit fc3f43b4725837214206d9c7f1b3efb20b267748
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Nov 16 20:38:14 2025 +0000
Make admin forms to delete keys and releases more type safe
---
atr/admin/__init__.py | 215 +++++++++++++++++--------------
atr/admin/templates/delete-release.html | 36 +-----
atr/blueprints/admin.py | 10 +-
atr/post/finish.py | 5 +-
atr/templates/delete-committee-keys.html | 22 +---
playwright/test.py | 16 +--
scripts/docs_check.py | 3 +-
7 files changed, 139 insertions(+), 168 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 9153f6d..30bfde7 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -30,6 +30,8 @@ import aiohttp
import asfquart
import asfquart.base as base
import asfquart.session
+import htpy
+import pydantic
import quart
import sqlalchemy.orm as orm
@@ -41,6 +43,7 @@ import atr.db.interaction as interaction
import atr.form as form
import atr.forms as forms
import atr.get as get
+import atr.htm as htm
import atr.ldap as ldap
import atr.log as log
import atr.mapping as mapping
@@ -62,26 +65,35 @@ class BrowseAsUserForm(form.Form):
uid: str = form.label("ASF UID", "Enter the ASF UID to browse as.")
-class DeleteCommitteeKeysForm(forms.Typed):
- committee_name = forms.select("Committee")
- confirm_delete = forms.string(
- "Confirmation",
- validators=forms.constant("DELETE KEYS"),
- placeholder="DELETE KEYS",
- )
- submit = forms.submit("Delete all keys for selected committee")
+class DeleteCommitteeKeysForm(form.Form):
+ committee_name: str = form.label("Committee", widget=form.Widget.SELECT)
+ confirm_delete: str = form.label("Confirmation", "Type DELETE KEYS to
confirm")
+ @pydantic.field_validator("confirm_delete")
+ @classmethod
+ def validate_confirm_delete(cls, v: str) -> str:
+ if v != "DELETE KEYS":
+ raise ValueError("You must type DELETE KEYS exactly to confirm
deletion")
+ return v
-class DeleteReleaseForm(forms.Typed):
- """Form for deleting releases."""
- confirm_delete = forms.string(
- "Confirmation",
- validators=forms.constant("DELETE"),
- placeholder="DELETE",
- description="Please type DELETE exactly to confirm deletion.",
- )
- submit = forms.submit("Delete selected releases permanently")
+class DeleteReleaseForm(form.Form):
+ releases_to_delete: form.StrList = form.label("Select releases to delete",
widget=form.Widget.CUSTOM)
+ confirm_delete: str = form.label("Confirmation", "Please type DELETE
exactly to confirm deletion.")
+
+ @pydantic.field_validator("confirm_delete")
+ @classmethod
+ def validate_confirm_delete(cls, v: str) -> str:
+ if v != "DELETE":
+ raise ValueError("You must type DELETE exactly to confirm
deletion")
+ return v
+
+ @pydantic.field_validator("releases_to_delete")
+ @classmethod
+ def validate_releases_to_delete(cls, v: form.StrList) -> form.StrList:
+ if len(v) == 0:
+ raise ValueError("You must select at least one release to delete")
+ return v
class LdapLookupForm(forms.Typed):
@@ -283,7 +295,7 @@ async def _data(session: web.Committer, model: str =
"Committee") -> str:
@admin.get("/delete-test-openpgp-keys")
async def delete_test_openpgp_keys_get(session: web.Committer) -> web.Response:
- """Display form to delete test user OpenPGP keys."""
+ """Display the form to delete test user OpenPGP keys."""
if not config.get().ALLOW_TESTS:
raise base.ASFQuartException("Test operations are disabled in this
environment", errorcode=403)
@@ -323,102 +335,114 @@ async def delete_test_openpgp_keys_post(session:
web.Committer) -> web.Response:
@admin.get("/delete-committee-keys")
async def delete_committee_keys_get(session: web.Committer) -> str |
web.WerkzeugResponse:
- return await _delete_committee_keys(session)
-
-
[email protected]("/delete-committee-keys")
-async def delete_committee_keys_post(session: web.Committer) -> str |
web.WerkzeugResponse:
- return await _delete_committee_keys(session)
-
-
-async def _delete_committee_keys(session: web.Committer) -> str |
web.WerkzeugResponse:
- form = await DeleteCommitteeKeysForm.create_form()
+ """Display the form to delete committee keys."""
async with db.session() as data:
all_committees = await
data.committee(_public_signing_keys=True).order_by(sql.Committee.name).all()
committees_with_keys = [c for c in all_committees if
c.public_signing_keys]
- form.committee_name.choices = [(c.name, c.display_name) for c in
committees_with_keys]
-
- if await form.validate_on_submit():
- committee_name = form.committee_name.data
- async with db.session() as data:
- committee_query = data.committee(name=committee_name)
- via = sql.validate_instrumented_attribute
- committee_query.query = committee_query.query.options(
-
orm.selectinload(via(sql.Committee.public_signing_keys)).selectinload(
- via(sql.PublicSigningKey.committees)
- )
- )
- committee = await committee_query.get()
- if not committee:
- await quart.flash(f"Committee '{committee_name}' not found.",
"error")
- return await session.redirect(delete_committee_keys_get)
+ committee_choices = [(c.name, c.display_name) for c in
committees_with_keys]
- keys_to_check = list(committee.public_signing_keys)
- if not keys_to_check:
- await quart.flash(f"Committee '{committee_name}' has no
keys.", "info")
- return await session.redirect(delete_committee_keys_get)
+ rendered_form = form.render(
+ model_cls=DeleteCommitteeKeysForm,
+ submit_label="Delete all keys for selected committee",
+ defaults={"committee_name": committee_choices},
+ )
+ return await template.render("delete-committee-keys.html",
form=rendered_form)
- num_removed = len(committee.public_signing_keys)
- committee.public_signing_keys.clear()
- await data.flush()
- unused_deleted = 0
- for key_obj in keys_to_check:
- if not key_obj.committees:
- await data.delete(key_obj)
- unused_deleted += 1
[email protected]("/delete-committee-keys")
[email protected](DeleteCommitteeKeysForm)
+async def delete_committee_keys_post(
+ session: web.Committer, delete_form: DeleteCommitteeKeysForm
+) -> str | web.WerkzeugResponse:
+ """Delete all keys for selected committee."""
+ committee_name = delete_form.committee_name
- await data.commit()
- await quart.flash(
- f"Removed {num_removed} key links for '{committee_name}'.
Deleted {unused_deleted} unused keys.",
- "success",
- )
- return await session.redirect(delete_committee_keys_get)
+ async with db.session() as data:
+ committee_query = data.committee(name=committee_name)
+ via = sql.validate_instrumented_attribute
+ committee_query.query = committee_query.query.options(
+
orm.selectinload(via(sql.Committee.public_signing_keys)).selectinload(via(sql.PublicSigningKey.committees))
+ )
+ committee = await committee_query.get()
- elif quart.request.method == "POST":
- await quart.flash("Form validation failed. Select committee and type
DELETE KEYS.", "warning")
+ if not committee:
+ await quart.flash(f"Committee '{committee_name}' not found.",
"error")
+ return await session.redirect(delete_committee_keys_get)
- return await template.render("delete-committee-keys.html", form=form)
+ keys_to_check = list(committee.public_signing_keys)
+ if not keys_to_check:
+ await quart.flash(f"Committee '{committee_name}' has no keys.",
"info")
+ return await session.redirect(delete_committee_keys_get)
+ num_removed = len(committee.public_signing_keys)
+ committee.public_signing_keys.clear()
+ await data.flush()
[email protected]("/delete-release")
-async def delete_release_get(session: web.Committer) -> str |
web.WerkzeugResponse:
- return await _delete_release(session)
+ unused_deleted = 0
+ for key_obj in keys_to_check:
+ if not key_obj.committees:
+ await data.delete(key_obj)
+ unused_deleted += 1
+ await data.commit()
+ await quart.flash(
+ f"Removed {num_removed} key links for '{committee_name}'. Deleted
{unused_deleted} unused keys.",
+ "success",
+ )
[email protected]("/delete-release")
-async def delete_release_post(session: web.Committer) -> str |
web.WerkzeugResponse:
- return await _delete_release(session)
+ return await session.redirect(delete_committee_keys_get)
-async def _delete_release(session: web.Committer) -> str |
web.WerkzeugResponse:
- """Page to delete selected releases and their associated data and files."""
- form = await DeleteReleaseForm.create_form()
[email protected]("/delete-release")
+async def delete_release_get(session: web.Committer) -> str |
web.WerkzeugResponse:
+ """Display the form to delete releases."""
+ async with db.session() as data:
+ releases = await
data.release(_project=True).order_by(sql.Release.name).all()
- if quart.request.method == "POST":
- if await form.validate_on_submit():
- form_data = await quart.request.form
- releases_to_delete = form_data.getlist("releases_to_delete")
+ if releases:
+ releases_widget = htpy.div[
+ [
+ htpy.div(".form-check")[
+ htpy.input(
+ class_="form-check-input",
+ type="checkbox",
+ name="releases_to_delete",
+ value=release.name,
+ id=f"release_{release.name}",
+ ),
+ htpy.label(".form-check-label",
for_=f"release_{release.name}")[
+ htpy.strong[release.name],
+ f" ({release.project.display_name},
{release.phase.value.upper()})",
+ ],
+ ]
+ for release in releases
+ ]
+ ]
+ block = htm.Block(htm.div)
+ block.append(releases_widget)
+ block.div(".form-text.mt-1")["Select one or more releases to delete
permanently."]
+ releases_widget_with_help = block.collect()
+ else:
+ releases_widget_with_help = htpy.p(".text-muted")["No releases found
in the database."]
- if not releases_to_delete:
- await quart.flash("No releases selected for deletion.",
"warning")
- return await session.redirect(delete_release_get)
+ rendered_form = form.render(
+ model_cls=DeleteReleaseForm,
+ submit_label="Delete selected releases permanently",
+ submit_classes="btn-danger",
+ custom={"releases_to_delete": releases_widget_with_help},
+ )
- await _delete_releases(session, releases_to_delete)
+ return await template.render("delete-release.html", form=rendered_form)
- # Redirecting back to the deletion page will refresh the list of
releases too
- return await session.redirect(delete_release_get)
- # It's unlikely that form validation failed due to spurious release
names
- # Therefore we assume that the user forgot to type DELETE to confirm
- await quart.flash("Form validation failed. Please type DELETE to
confirm.", "warning")
- # Fall through to the combined GET and failed form validation handling
below
[email protected]("/delete-release")
[email protected](DeleteReleaseForm)
+async def delete_release_post(session: web.Committer, delete_form:
DeleteReleaseForm) -> str | web.WerkzeugResponse:
+ """Delete selected releases and their associated data and files."""
+ await _delete_releases(session, delete_form.releases_to_delete)
- # For GET request or failed form validation
- async with db.session() as data:
- releases = await
data.release(_project=True).order_by(sql.Release.name).all()
- return await template.render("delete-release.html", form=form,
releases=releases, stats=None)
+ return await session.redirect(delete_release_get)
@admin.get("/env")
@@ -454,7 +478,7 @@ async def keys_check_post(session: web.Committer) ->
web.QuartResponse:
@admin.get("/keys/regenerate-all")
async def keys_regenerate_all_get(session: web.Committer) -> web.QuartResponse:
- """Display form to regenerate KEYS files."""
+ """Display the form to regenerate KEYS files."""
rendered_form = form.render(
model_cls=form.Empty,
submit_label="Regenerate all KEYS files",
@@ -865,11 +889,12 @@ async def _delete_releases(session: web.Committer,
releases_to_delete: list[str]
fail_count += 1
error_messages.append(f"{release_name}: Unexpected error ({e})")
+ releases = "release" if (success_count == 1) else "releases"
if success_count > 0:
- await quart.flash(f"Successfully deleted {success_count} release(s).",
"success")
+ await quart.flash(f"Successfully deleted {success_count} {releases}.",
"success")
if fail_count > 0:
errors_str = "\n".join(error_messages)
- await quart.flash(f"Failed to delete {fail_count}
release(s):\n{errors_str}", "error")
+ await quart.flash(f"Failed to delete {fail_count}
{releases}:\n{errors_str}", "error")
async def _get_filesystem_dirs() -> list[str]:
diff --git a/atr/admin/templates/delete-release.html
b/atr/admin/templates/delete-release.html
index b732642..2e721a8 100644
--- a/atr/admin/templates/delete-release.html
+++ b/atr/admin/templates/delete-release.html
@@ -15,40 +15,6 @@
<strong>Warning:</strong> This action is irreversible. Deleting a release
will permanently remove its database records, including tasks and check
results, and its associated files from the filesystem.
</div>
- <form method="post" novalidate>
- {{ form.hidden_tag() }}
-
- <div class="mb-3">
- <label class="form-label">Select releases to delete:</label>
- {% if releases %}
- <div class="list-group overflow-y-auto border rounded">
- {% for release in releases %}
- <label class="list-group-item list-group-item-action d-flex gap-3">
- <input class="form-check-input flex-shrink-0"
- type="checkbox"
- name="releases_to_delete"
- value="{{ release.name }}" />
- <span>
- <strong>{{ release.name }}</strong> ({{
release.project.display_name }}, Phase: {{ release.phase.value.upper() }})
- </span>
- </label>
- {% endfor %}
- </div>
- <div class="form-text">Select one or more releases to delete
permanently.</div>
- {% else %}
- <p class="text-muted">No releases found in the database.</p>
- {% endif %}
- </div>
-
- <div class="mb-3">
- {{ forms.label(form.confirm_delete) }}
- {{ forms.widget(form.confirm_delete) }}
- {{ forms.errors(form.confirm_delete) }}
- {{ forms.description(form.confirm_delete) }}
- </div>
-
- {{ form.submit(class="btn btn-danger") }}
-
- </form>
+ {{ form }}
{% endblock content %}
diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index 88eeeab..c3318d3 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -48,14 +48,14 @@ async def _check_admin_access() -> None:
def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[...,
Awaitable[Any]]]:
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[...,
Awaitable[Any]]:
async def wrapper(session: web.Committer, *args: Any, **kwargs: Any)
-> Any:
- form_data = await quart.request.form
+ form_data = await atr.form.quart_request()
try:
context = {
"args": args,
"kwargs": kwargs,
"session": session,
}
- atr.form.validate(atr.form.Empty, dict(form_data),
context=context)
+ atr.form.validate(atr.form.Empty, form_data, context=context)
return await func(session, *args, **kwargs)
except pydantic.ValidationError:
msg = "Sorry, there was an empty form validation error. Please
try again."
@@ -76,20 +76,20 @@ def form(
) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[...,
Awaitable[Any]]:
async def wrapper(session: web.Committer, *args: Any, **kwargs: Any)
-> Any:
- form_data = await quart.request.form
+ form_data = await atr.form.quart_request()
try:
context = {
"args": args,
"kwargs": kwargs,
"session": session,
}
- validated_form = atr.form.validate(form_cls, dict(form_data),
context=context)
+ validated_form = atr.form.validate(form_cls, form_data,
context=context)
return await func(session, validated_form, *args, **kwargs)
except pydantic.ValidationError as e:
errors = e.errors()
if len(errors) == 0:
raise RuntimeError("Validation failed, but no errors were
reported")
- flash_data = atr.form.flash_error_data(form_cls, errors,
dict(form_data))
+ flash_data = atr.form.flash_error_data(form_cls, errors,
form_data)
summary = atr.form.flash_error_summary(errors, flash_data)
await quart.flash(summary, category="error")
diff --git a/atr/post/finish.py b/atr/post/finish.py
index 03c5ca6..7df9a76 100644
--- a/atr/post/finish.py
+++ b/atr/post/finish.py
@@ -124,15 +124,16 @@ async def _remove_rc_tags(
if creation_error is not None:
return await respond(409, creation_error)
+ items = "item" if (renamed_count == 1) else "items"
if error_messages:
status_ok = renamed_count > 0
# TODO: Ideally HTTP would have a general mixed status, like 207
but for anything
http_status = 200 if status_ok else 500
- msg = f"RC tags removed for {renamed_count} item(s) with some
errors: {'; '.join(error_messages)}"
+ msg = f"RC tags removed for {renamed_count} {items} with some
errors: {'; '.join(error_messages)}"
return await respond(http_status, msg)
if renamed_count > 0:
- return await respond(200, f"Successfully removed RC tags from
{renamed_count} item(s).")
+ return await respond(200, f"Successfully removed RC tags from
{renamed_count} {items}.")
return await respond(200, "No items required RC tag removal or no
changes were made.")
diff --git a/atr/templates/delete-committee-keys.html
b/atr/templates/delete-committee-keys.html
index 61e7611..2114c86 100644
--- a/atr/templates/delete-committee-keys.html
+++ b/atr/templates/delete-committee-keys.html
@@ -10,27 +10,7 @@
<div class="container mx-auto p-4">
<h1 class="mb-4">Delete all keys for a committee</h1>
- <form method="post"
- action="{{ as_url(admin.delete_committee_keys_post) }}"
- class="atr-canary py-4 px-5 border rounded">
- {{ form.csrf_token }}
-
- <div class="mb-3">
- {{ forms.label(form.committee_name) }}
- {{ forms.widget(form.committee_name, classes="form-select",
id=form.committee_name.id) }}
- {{ forms.errors(form.committee_name) }}
- {{ forms.description(form.committee_name) }}
- </div>
-
- <div class="mb-3">
- {{ forms.label(form.confirm_delete) }}
- {{ forms.widget(form.confirm_delete, classes="form-control",
id=form.confirm_delete.id) }}
- {{ forms.errors(form.confirm_delete) }}
- {{ forms.description(form.confirm_delete) }}
- </div>
-
- <div class="mt-4">{{ form.submit(class_="btn btn-danger") }}</div>
- </form>
+ {{ form }}
</div>
{% endblock content %}
diff --git a/playwright/test.py b/playwright/test.py
index a7bd112..df51509 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -226,7 +226,7 @@ def lifecycle_05_resolve_vote(page: sync_api.Page,
credentials: Credentials, ver
tabulate_form_locator =
page.locator(f'form[action="/resolve/{TEST_PROJECT}/{version_name}"]')
sync_api.expect(tabulate_form_locator).to_be_visible()
- tabulate_button_locator =
tabulate_form_locator.locator('button[type="submit"]:has-text("Resolve vote")')
+ tabulate_button_locator = tabulate_form_locator.get_by_role("button",
name="Resolve vote")
sync_api.expect(tabulate_button_locator).to_be_enabled()
logging.info("Clicking 'Tabulate votes' button")
tabulate_button_locator.click()
@@ -406,7 +406,7 @@ def release_remove(page: sync_api.Page, release_name: str)
-> None:
page.locator("#confirm_delete").fill("DELETE")
logging.info(f"Submitting deletion form for {release_name}")
- submit_button_locator =
page.locator('input[type="submit"][value="Delete selected releases
permanently"]')
+ submit_button_locator = page.get_by_role("button", name="Delete
selected releases permanently")
sync_api.expect(submit_button_locator).to_be_enabled()
submit_button_locator.click()
@@ -417,7 +417,7 @@ def release_remove(page: sync_api.Page, release_name: str)
-> None:
logging.info(f"Checking for success flash message for {release_name}")
flash_message_locator = page.locator("div.flash-success")
sync_api.expect(flash_message_locator).to_be_visible()
- sync_api.expect(flash_message_locator).to_contain_text("Successfully
deleted 1 release(s)")
+ sync_api.expect(flash_message_locator).to_contain_text("Successfully
deleted 1 release")
logging.info(f"Deletion successful for {release_name}")
else:
logging.info(f"Could not find the {release_name} release, no deletion
needed")
@@ -882,7 +882,7 @@ def test_login(page: sync_api.Page, credentials:
Credentials) -> None:
page.locator('input[name="password"]').fill(credentials.password)
logging.info("Submitting the login form")
- submit_button_locator =
page.locator('input[type="submit"][value="Authenticate"]')
+ submit_button_locator = page.get_by_role("button", name="Authenticate")
sync_api.expect(submit_button_locator).to_be_enabled()
submit_button_locator.click()
@@ -977,7 +977,7 @@ def test_projects_03_add_project(page: sync_api.Page,
credentials: Credentials)
page.locator('input[name="label"]').fill(project_label)
logging.info("Submitting the add derived project form")
- submit_button_locator = page.locator('input[type="submit"][value="Add
project"]')
+ submit_button_locator = page.get_by_role("button", name="Add project")
sync_api.expect(submit_button_locator).to_be_enabled()
submit_button_locator.click()
@@ -1145,7 +1145,7 @@ def test_tidy_up_openpgp_keys(page: sync_api.Page) ->
None:
# Navigate to the delete route and submit the form
go_to_path(page, "/admin/delete-test-openpgp-keys")
- delete_button = page.locator('button[type="submit"]')
+ delete_button = page.get_by_role("button", name="Delete all OpenPGP keys
for test user")
if delete_button.is_visible():
delete_button.click()
page.wait_for_load_state()
@@ -1200,9 +1200,7 @@ def test_tidy_up_openpgp_keys_continued(page:
sync_api.Page, fingerprints_to_del
logging.info(f"Locating delete form for fingerprint: {fingerprint}")
# Locate again by fingerprint for robustness
row_to_delete_locator =
page.locator(f'tr:has(a[href="/keys/details/{fingerprint}"])')
- delete_button_locator = row_to_delete_locator.locator(
- 'form[action="/keys"] input[type="submit"][value="Delete key"]'
- )
+ delete_button_locator = row_to_delete_locator.get_by_role("button",
name="Delete key")
if delete_button_locator.is_visible():
logging.info(f"Delete button found for {fingerprint}, proceeding
with deletion")
diff --git a/scripts/docs_check.py b/scripts/docs_check.py
index 3c9b906..a289302 100755
--- a/scripts/docs_check.py
+++ b/scripts/docs_check.py
@@ -191,7 +191,8 @@ def main() -> None:
print("Documentation link validation errors:\n", file=sys.stderr)
for error in errors:
print(error, file=sys.stderr)
- print(f"\nFound {len(errors)} error(s)", file=sys.stderr)
+ suffix = "s" if (len(errors) > 1) else ""
+ print(f"\nFound {len(errors)} error{suffix}", file=sys.stderr)
sys.exit(1)
print(f"Validated {len(all_links)} links across
{len(list(docs_dir.glob('*.md')))} files")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]