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 766dabb Make several admin forms more type safe
766dabb is described below
commit 766dabbd51bf457ae8cba92814088a59eda14d09
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Nov 16 19:58:49 2025 +0000
Make several admin forms more type safe
---
atr/admin/__init__.py | 190 ++++++++++++++---------------
atr/admin/templates/browse-as.html | 22 +---
atr/admin/templates/toggle-admin-view.html | 12 +-
atr/admin/templates/update-keys.html | 80 ++++++------
atr/admin/templates/update-projects.html | 80 ++++++------
atr/blueprints/admin.py | 73 ++++++++++-
atr/storage/writers/keys.py | 6 +-
7 files changed, 247 insertions(+), 216 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index a3795bd..9153f6d 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -38,6 +38,7 @@ import atr.config as config
import atr.datasources.apache as apache
import atr.db as db
import atr.db.interaction as interaction
+import atr.form as form
import atr.forms as forms
import atr.get as get
import atr.ldap as ldap
@@ -57,15 +58,8 @@ import atr.web as web
ROUTES_MODULE: Final[Literal[True]] = True
-class BrowseAsUserForm(forms.Typed):
- """Form for browsing as another user."""
-
- uid = forms.string("ASF UID", placeholder="Enter the ASF UID to browse as")
- submit = forms.submit("Browse as this user")
-
-
-class CheckKeysForm(forms.Typed):
- submit = forms.submit("Check public signing key details")
+class BrowseAsUserForm(form.Form):
+ uid: str = form.label("ASF UID", "Enter the ASF UID to browse as.")
class DeleteCommitteeKeysForm(forms.Typed):
@@ -90,20 +84,12 @@ class DeleteReleaseForm(forms.Typed):
submit = forms.submit("Delete selected releases permanently")
-class DeleteTestKeysForm(forms.Typed):
- submit = forms.submit("Delete all OpenPGP keys for test user")
-
-
class LdapLookupForm(forms.Typed):
uid = forms.optional("ASF UID (optional)", placeholder="Enter ASF UID,
e.g. johnsmith, or * for all")
email = forms.optional("Email address (optional)", placeholder="Enter
email address, e.g. [email protected]")
submit = forms.submit("Lookup")
-class RegenerateKeysForm(forms.Typed):
- submit = forms.submit("Regenerate all KEYS files")
-
-
@admin.get("/all-releases")
async def all_releases(session: web.Committer) -> str:
"""Display a list of all releases across all phases."""
@@ -114,24 +100,22 @@ async def all_releases(session: web.Committer) -> str:
@admin.get("/browse-as")
async def browse_as_get(session: web.Committer) -> str | web.WerkzeugResponse:
- return await _browse_as(session)
+ """Allows an admin to browse as another user."""
+ rendered_form = form.render(
+ model_cls=BrowseAsUserForm,
+ submit_label="Browse as this user",
+ )
+ return await template.render("browse-as.html", form=rendered_form)
@admin.post("/browse-as")
-async def browse_as_post(session: web.Committer) -> str | web.WerkzeugResponse:
- return await _browse_as(session)
-
-
-async def _browse_as(session: web.Committer) -> str | web.WerkzeugResponse:
[email protected](BrowseAsUserForm)
+async def browse_as_post(session: web.Committer, browse_form:
BrowseAsUserForm) -> str | web.WerkzeugResponse:
"""Allows an admin to browse as another user."""
# TODO: Enable this in debugging mode only?
import atr.get.root as root
- form = await BrowseAsUserForm.create_form()
- if not (await form.validate_on_submit()):
- return await template.render("browse-as.html", form=form)
-
- new_uid = str(util.unwrap(form.uid.data))
+ new_uid = browse_form.uid
if not (current_session := await asfquart.session.read()):
raise base.ASFQuartException("Not authenticated", 401)
@@ -299,29 +283,40 @@ 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."""
if not config.get().ALLOW_TESTS:
raise base.ASFQuartException("Test operations are disabled in this
environment", errorcode=403)
- delete_form = await DeleteTestKeysForm.create_form()
- rendered_form = forms.render_simple(delete_form, action="")
+ rendered_form = form.render(
+ model_cls=form.Empty,
+ submit_label="Delete all OpenPGP keys for test user",
+ empty=True,
+ )
return web.ElementResponse(rendered_form)
@admin.post("/delete-test-openpgp-keys")
[email protected]()
async def delete_test_openpgp_keys_post(session: web.Committer) ->
web.Response:
"""Delete all test user OpenPGP keys and their links."""
if not config.get().ALLOW_TESTS:
raise base.ASFQuartException("Test operations are disabled in this
environment", errorcode=403)
test_uid = "test"
- delete_form = await DeleteTestKeysForm.create_form()
- if not await delete_form.validate_on_submit():
- raise base.ASFQuartException("Invalid form submission. Please check
your input and try again.", errorcode=400)
-
- async with storage.write() as write:
- wafc = write.as_foundation_committer()
- outcome = await wafc.keys.test_user_delete_all(test_uid)
- outcome.result_or_raise()
+ try:
+ async with storage.write() as write:
+ wafc = write.as_foundation_committer()
+ delete_outcome = await wafc.keys.test_user_delete_all(test_uid)
+ deleted_count = delete_outcome.result_or_raise()
+
+ suffix = "s" if deleted_count != 1 else ""
+ await quart.flash(
+ f"Successfully deleted {deleted_count} OpenPGP key{suffix} and
their associated links for test user.",
+ "success",
+ )
+ except Exception as e:
+ log.exception("Error deleting test user keys:")
+ await quart.flash(f"Error deleting test user keys: {e!s}", "error")
return await session.redirect(get.keys.keys)
@@ -437,21 +432,18 @@ async def env(session: web.Committer) ->
web.QuartResponse:
@admin.get("/keys/check")
async def keys_check_get(session: web.Committer) -> web.QuartResponse:
- return await _keys_check(session)
+ """Check public signing key details."""
+ rendered_form = form.render(
+ model_cls=form.Empty,
+ submit_label="Check public signing key details",
+ empty=True,
+ )
+ return web.ElementResponse(rendered_form)
@admin.post("/keys/check")
async def keys_check_post(session: web.Committer) -> web.QuartResponse:
- return await _keys_check(session)
-
-
-async def _keys_check(session: web.Committer) -> web.QuartResponse:
"""Check public signing key details."""
- if quart.request.method != "POST":
- check_form = await CheckKeysForm.create_form()
- rendered_form = forms.render_simple(check_form, action="")
- return web.ElementResponse(rendered_form)
-
try:
result = await _check_keys()
return web.TextResponse(result)
@@ -462,21 +454,18 @@ async def _keys_check(session: web.Committer) ->
web.QuartResponse:
@admin.get("/keys/regenerate-all")
async def keys_regenerate_all_get(session: web.Committer) -> web.QuartResponse:
- return await _keys_regenerate_all(session)
+ """Display form to regenerate KEYS files."""
+ rendered_form = form.render(
+ model_cls=form.Empty,
+ submit_label="Regenerate all KEYS files",
+ empty=True,
+ )
+ return web.ElementResponse(rendered_form)
@admin.post("/keys/regenerate-all")
async def keys_regenerate_all_post(session: web.Committer) ->
web.QuartResponse:
- return await _keys_regenerate_all(session)
-
-
-async def _keys_regenerate_all(session: web.Committer) -> web.QuartResponse:
"""Regenerate the KEYS file for all committees."""
- if quart.request.method != "POST":
- regenerate_form = await RegenerateKeysForm.create_form()
- rendered_form = forms.render_simple(regenerate_form, action="")
- return web.ElementResponse(rendered_form)
-
async with db.session() as data:
committee_names = [c.name for c in await data.committee().all()]
@@ -500,27 +489,25 @@ async def _keys_regenerate_all(session: web.Committer) ->
web.QuartResponse:
@admin.get("/keys/update")
async def keys_update_get(session: web.Committer) -> str |
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
- return await _keys_update(session)
+ """Update keys from remote data."""
+ rendered_form = form.render(
+ model_cls=form.Empty,
+ submit_label="Update keys",
+ empty=True,
+ form_classes="",
+ )
+ log_path = pathlib.Path("keys_import.log")
+ if not await aiofiles.os.path.exists(log_path):
+ previous_output = None
+ else:
+ async with aiofiles.open(log_path) as f:
+ previous_output = await f.read()
+ return await template.render("update-keys.html", empty_form=rendered_form,
previous_output=previous_output)
@admin.post("/keys/update")
async def keys_update_post(session: web.Committer) -> str |
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
- return await _keys_update(session)
-
-
-async def _keys_update(session: web.Committer) -> str | web.WerkzeugResponse |
tuple[Mapping[str, Any], int]:
"""Update keys from remote data."""
- if quart.request.method != "POST":
- empty_form = await forms.Empty.create_form()
- # Get the previous output from the log file
- log_path = pathlib.Path("keys_import.log")
- if not await aiofiles.os.path.exists(log_path):
- previous_output = None
- else:
- async with aiofiles.open(log_path) as f:
- previous_output = await f.read()
- return await template.render("update-keys.html",
empty_form=empty_form, previous_output=previous_output)
-
try:
pid = await _update_keys(session.asf_uid)
return {
@@ -702,33 +689,31 @@ async def performance(session: web.Committer) -> str:
@admin.get("/projects/update")
async def projects_update_get(session: web.Committer) -> str |
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
- return await _projects_update(session)
+ """Update projects from remote data."""
+ rendered_form = form.render(
+ model_cls=form.Empty,
+ submit_label="Update projects",
+ empty=True,
+ form_classes="",
+ )
+ return await template.render("update-projects.html",
empty_form=rendered_form)
@admin.post("/projects/update")
async def projects_update_post(session: web.Committer) -> str |
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
- return await _projects_update(session)
-
-
-async def _projects_update(session: web.Committer) -> str |
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
"""Update projects from remote data."""
- if quart.request.method == "POST":
- try:
- task = await tasks.metadata_update(session.asf_uid)
- return {
- "message": f"Metadata update task has been queued with ID
{task.id}.",
- "category": "success",
- }, 200
- except Exception as e:
- log.exception("Failed to queue metadata update task")
- return {
- "message": f"Failed to queue metadata update: {e!s}",
- "category": "error",
- }, 200
-
- # For GET requests, show the update form
- empty_form = await forms.Empty.create_form()
- return await template.render("update-projects.html", empty_form=empty_form)
+ try:
+ task = await tasks.metadata_update(session.asf_uid)
+ return {
+ "message": f"Metadata update task has been queued with ID
{task.id}.",
+ "category": "success",
+ }, 200
+ except Exception as e:
+ log.exception("Failed to queue metadata update task")
+ return {
+ "message": f"Failed to queue metadata update: {e!s}",
+ "category": "error",
+ }, 200
@admin.get("/tasks")
@@ -790,14 +775,19 @@ async def test(session: web.Committer) ->
web.QuartResponse:
@admin.get("/toggle-view")
async def toggle_view_get(session: web.Committer) -> str:
"""Display the page with a button to toggle between admin and user
views."""
- empty_form = await forms.Empty.create_form()
- return await template.render("toggle-admin-view.html",
empty_form=empty_form)
+ rendered_form = form.render(
+ model_cls=form.Empty,
+ submit_label="Toggle view",
+ empty=True,
+ form_classes=".mb-4",
+ )
+ return await template.render("toggle-admin-view.html",
empty_form=rendered_form)
@admin.post("/toggle-view")
[email protected]()
async def toggle_view_post(session: web.Committer) -> web.WerkzeugResponse:
- await util.validate_empty_form()
-
+ """Toggle between admin and user views."""
app = asfquart.APP
if not hasattr(app, "app_id") or not isinstance(app.app_id, str):
raise TypeError("Internal error: APP has no valid app_id")
diff --git a/atr/admin/templates/browse-as.html
b/atr/admin/templates/browse-as.html
index 83b8830..28036f5 100644
--- a/atr/admin/templates/browse-as.html
+++ b/atr/admin/templates/browse-as.html
@@ -14,25 +14,5 @@
This will allow you to view the site as that user.
</p>
- <form method="post" class="form">
- {{ form.csrf_token }}
- <div class="field mb-3">
- <label class="label" for="uid">{{ form.uid.label }}</label>
- <div class="control">
- {{ form.uid(class="form-control") }}
- </div>
- {% if form.uid.errors %}
- <div class="invalid-feedback d-block">
- {% for error in form.uid.errors %}
- <span>{{ error }}</span>
- {% endfor %}
- </div>
- {% endif %}
- </div>
- <div class="field">
- <div class="control">
- {{ form.submit(class="btn btn-primary") }}
- </div>
- </div>
- </form>
+ {{ form }}
{% endblock %}
diff --git a/atr/admin/templates/toggle-admin-view.html
b/atr/admin/templates/toggle-admin-view.html
index b39910b..2dd7bf9 100644
--- a/atr/admin/templates/toggle-admin-view.html
+++ b/atr/admin/templates/toggle-admin-view.html
@@ -15,17 +15,7 @@
</p>
{% if current_user and is_admin_fn(current_user.uid) %}
- <form action="{{ as_url(admin.toggle_view_post) }}" method="post"
class="mb-4">
- {{ empty_form.hidden_tag() }}
-
- <button type="submit" class="btn btn-primary">
- {% if not is_viewing_as_admin_fn(current_user.uid) %}
- <i class="fa-solid fa-user-shield"></i> Switch to admin view
- {% else %}
- <i class="fa-solid fa-user-ninja"></i> Switch to user view
- {% endif %}
- </button>
- </form>
+ {{ empty_form }}
<div class="alert alert-info" role="alert">
Current view mode:
diff --git a/atr/admin/templates/update-keys.html
b/atr/admin/templates/update-keys.html
index 4c23eed..2184c12 100644
--- a/atr/admin/templates/update-keys.html
+++ b/atr/admin/templates/update-keys.html
@@ -85,11 +85,7 @@
<div id="status"></div>
- <form action="javascript:submitForm().then(_ => { return false; })">
- {{ empty_form.hidden_tag() }}
-
- <button type="submit" id="submitButton">Update keys</button>
- </form>
+ {{ empty_form }}
{% if previous_output %}
<h2>Previous output</h2>
@@ -97,40 +93,46 @@
{% endif %}
<script>
- const submitForm = async () => {
- const button = document.getElementById("submitButton");
- button.disabled = true;
- document.body.style.cursor = "wait";
-
- const statusElement = document.getElementById("status");
- while (statusElement.firstChild) {
- statusElement.firstChild.remove();
- }
-
- const csrfToken =
document.querySelector("input[name='csrf_token']").value;
-
- try {
- const response = await fetch(window.location.href, {
- method: "POST",
- headers: {
- "X-CSRFToken": csrfToken
- }
- });
-
- if (!response.ok) {
- addStatusMessage(statusElement, "Could not make network
request", "error");
- return
- }
-
- const data = await response.json();
- addStatusMessage(statusElement, data.message, data.category)
- } catch (error) {
- addStatusMessage(statusElement, error, "error")
- } finally {
- button.disabled = false;
- document.body.style.cursor = "default";
- }
- };
+ document.addEventListener('DOMContentLoaded', () => {
+ const form = document.querySelector('form');
+ const button = form.querySelector('button[type="submit"]');
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ button.disabled = true;
+ document.body.style.cursor = "wait";
+
+ const statusElement = document.getElementById("status");
+ while (statusElement.firstChild) {
+ statusElement.firstChild.remove();
+ }
+
+ const csrfToken =
document.querySelector("input[name='csrf_token']").value;
+
+ try {
+ const response = await fetch(window.location.href, {
+ method: "POST",
+ headers: {
+ "X-CSRFToken": csrfToken
+ }
+ });
+
+ if (!response.ok) {
+ addStatusMessage(statusElement, "Could not make network
request", "error");
+ return
+ }
+
+ const data = await response.json();
+ addStatusMessage(statusElement, data.message, data.category)
+ } catch (error) {
+ addStatusMessage(statusElement, error, "error")
+ } finally {
+ button.disabled = false;
+ document.body.style.cursor = "default";
+ }
+ });
+ });
function addStatusMessage(parentElement, message, category) {
const divElement = document.createElement("div");
diff --git a/atr/admin/templates/update-projects.html
b/atr/admin/templates/update-projects.html
index c997001..a0f73cc 100644
--- a/atr/admin/templates/update-projects.html
+++ b/atr/admin/templates/update-projects.html
@@ -90,47 +90,49 @@
<div id="status"></div>
- <form action="javascript:submitForm().then(_ => { return false; })">
- {{ empty_form.hidden_tag() }}
-
- <button type="submit" id="submitButton">Update projects</button>
- </form>
+ {{ empty_form }}
<script>
- const submitForm = async () => {
- const button = document.getElementById("submitButton");
- button.disabled = true;
- document.body.style.cursor = "wait";
-
- const statusElement = document.getElementById("status");
- while (statusElement.firstChild) {
- statusElement.firstChild.remove();
- }
-
- const csrfToken =
document.querySelector("input[name='csrf_token']").value;
-
- try {
- const response = await fetch(window.location.href, {
- method: "POST",
- headers: {
- "X-CSRFToken": csrfToken
- }
- });
-
- if (!response.ok) {
- addStatusMessage(statusElement, "Could not make network
request", "error");
- return
- }
-
- const data = await response.json();
- addStatusMessage(statusElement, data.message, data.category)
- } catch (error) {
- addStatusMessage(statusElement, error, "error")
- } finally {
- button.disabled = false;
- document.body.style.cursor = "default";
- }
- };
+ document.addEventListener('DOMContentLoaded', () => {
+ const form = document.querySelector('form');
+ const button = form.querySelector('button[type="submit"]');
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ button.disabled = true;
+ document.body.style.cursor = "wait";
+
+ const statusElement = document.getElementById("status");
+ while (statusElement.firstChild) {
+ statusElement.firstChild.remove();
+ }
+
+ const csrfToken =
document.querySelector("input[name='csrf_token']").value;
+
+ try {
+ const response = await fetch(window.location.href, {
+ method: "POST",
+ headers: {
+ "X-CSRFToken": csrfToken
+ }
+ });
+
+ if (!response.ok) {
+ addStatusMessage(statusElement, "Could not make network
request", "error");
+ return
+ }
+
+ const data = await response.json();
+ addStatusMessage(statusElement, data.message, data.category)
+ } catch (error) {
+ addStatusMessage(statusElement, error, "error")
+ } finally {
+ button.disabled = false;
+ document.body.style.cursor = "default";
+ }
+ });
+ });
function addStatusMessage(parentElement, message, category) {
const divElement = document.createElement("div");
diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index baccd93..88eeeab 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -15,14 +15,17 @@
# specific language governing permissions and limitations
# under the License.
-from collections.abc import Callable
+import json
+from collections.abc import Awaitable, Callable
from types import ModuleType
from typing import Any
import asfquart.base as base
import asfquart.session
+import pydantic
import quart
+import atr.form
import atr.user as user
import atr.web as web
@@ -42,11 +45,64 @@ async def _check_admin_access() -> None:
quart.g.session = web.Committer(web_session)
-def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
- import atr.admin as admin
+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
+ try:
+ context = {
+ "args": args,
+ "kwargs": kwargs,
+ "session": session,
+ }
+ atr.form.validate(atr.form.Empty, dict(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."
+ await quart.flash(msg, "error")
+ return quart.redirect(quart.request.path)
+
+ wrapper.__annotations__ = func.__annotations__.copy()
+ wrapper.__doc__ = func.__doc__
+ wrapper.__module__ = func.__module__
+ wrapper.__name__ = func.__name__
+ return wrapper
- app.register_blueprint(_BLUEPRINT)
- return admin, []
+ return decorator
+
+
+def form(
+ form_cls: Any,
+) -> 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
+ try:
+ context = {
+ "args": args,
+ "kwargs": kwargs,
+ "session": session,
+ }
+ validated_form = atr.form.validate(form_cls, dict(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))
+ summary = atr.form.flash_error_summary(errors, flash_data)
+
+ await quart.flash(summary, category="error")
+ await quart.flash(json.dumps(flash_data),
category="form-error-data")
+ return quart.redirect(quart.request.path)
+
+ wrapper.__annotations__ = func.__annotations__.copy()
+ wrapper.__doc__ = func.__doc__
+ wrapper.__module__ = func.__module__
+ wrapper.__name__ = func.__name__
+ return wrapper
+
+ return decorator
def get(path: str) -> Callable[[web.CommitterRouteFunction[Any]],
web.RouteFunction[Any]]:
@@ -81,3 +137,10 @@ def post(path: str) ->
Callable[[web.CommitterRouteFunction[Any]], web.RouteFunc
return wrapper
return decorator
+
+
+def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
+ import atr.admin as admin
+
+ app.register_blueprint(_BLUEPRINT)
+ return admin, []
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index 4b71d1d..57a6f45 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -184,10 +184,14 @@ class FoundationCommitter(GeneralPublic):
return outcome.Error(storage.AccessError("Test key deletion not
enabled"))
try:
- test_user_keys = await
self.__data.public_signing_key(apache_uid=test_uid).all()
+ test_user_keys = await
self.__data.public_signing_key(apache_uid=test_uid, _committees=True).all()
deleted_count = 0
for key in test_user_keys:
+ # We must do this here otherwise SQLAlchemy does not know
about the deletions
+ key.committees.clear()
+ await self.__data.flush()
+
keylinks_query =
sqlmodel.select(sql.KeyLink).where(sql.KeyLink.key_fingerprint ==
key.fingerprint)
keylinks_result = await self.__data.execute(keylinks_query)
keylinks = keylinks_result.all()
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]