You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/07/31 15:18:42 UTC
(tooling-trusted-release) branch main updated: Simplify key, preview, and project forms
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-release.git
The following commit(s) were added to refs/heads/main by this push:
new f602d77 Simplify key, preview, and project forms
f602d77 is described below
commit f602d77a1e8950b40ebca2bdba56f7f18a3442de
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Jul 31 16:18:09 2025 +0100
Simplify key, preview, and project forms
---
atr/forms.py | 61 ++++++++++++++++++-
atr/routes/keys.py | 86 ++++++++++++---------------
atr/routes/preview.py | 21 +++----
atr/routes/projects.py | 128 +++++++++++++++-------------------------
atr/templates/project-view.html | 8 +--
5 files changed, 154 insertions(+), 150 deletions(-)
diff --git a/atr/forms.py b/atr/forms.py
index 6812647..5ae57a2 100644
--- a/atr/forms.py
+++ b/atr/forms.py
@@ -23,6 +23,7 @@ import quart_wtf
import quart_wtf.typing
import wtforms
+EMAIL: Final = wtforms.validators.Email()
REQUIRED: Final = wtforms.validators.InputRequired()
REQUIRED_DATA: Final = wtforms.validators.DataRequired()
OPTIONAL: Final = wtforms.validators.Optional()
@@ -84,21 +85,53 @@ def boolean(
return wtforms.BooleanField(label, **kwargs)
-def choices(field: wtforms.RadioField, choices: Choices, default: str | None = None) -> None:
+def checkboxes(
+ label: str, optional: bool = False, validators: list[Any] | None = None, **kwargs: Any
+) -> wtforms.SelectMultipleField:
+ if validators is None:
+ validators = []
+ if optional is False:
+ validators.append(REQUIRED)
+ else:
+ validators.append(OPTIONAL)
+ return wtforms.SelectMultipleField(
+ label,
+ validators=validators,
+ coerce=str,
+ option_widget=wtforms.widgets.CheckboxInput(),
+ widget=wtforms.widgets.ListWidget(prefix_label=False),
+ **kwargs,
+ )
+
+
+def choices(
+ field: wtforms.RadioField | wtforms.SelectMultipleField, choices: Choices, default: str | None = None
+) -> None:
field.choices = choices
# Form construction calls Field.process
# This sets data = self.default() or self.default
# Then self.object_data = data
# Then calls self.process_data(data) which sets self.data = data
# And SelectField.iter_choices reads self.data for the default
- if default is not None:
- field.data = default
+ if isinstance(field, wtforms.RadioField):
+ if default is not None:
+ field.data = default
def constant(value: str) -> list[wtforms.validators.InputRequired | wtforms.validators.Regexp]:
return [REQUIRED, wtforms.validators.Regexp(value, message=f"You must enter {value!r} in this field")]
+def file(label: str, optional: bool = False, validators: list[Any] | None = None, **kwargs: Any) -> wtforms.FileField:
+ if validators is None:
+ validators = []
+ if optional is False:
+ validators.append(REQUIRED)
+ else:
+ validators.append(OPTIONAL)
+ return wtforms.FileField(label, validators=validators, **kwargs)
+
+
def hidden(optional: bool = False, validators: list[Any] | None = None, **kwargs: Any) -> wtforms.HiddenField:
if validators is None:
validators = []
@@ -121,6 +154,7 @@ def integer(
return wtforms.IntegerField(label, validators=validators, **kwargs)
+# TODO: Do we need this?
def multiple(label: str, validators: list[Any] | None = None, **kwargs: Any) -> wtforms.SelectMultipleField:
if validators is None:
validators = [REQUIRED]
@@ -178,6 +212,7 @@ def textarea(
optional: bool = False,
validators: list[Any] | None = None,
placeholder: str | None = None,
+ rows: int | None = None,
**kwargs: Any,
) -> wtforms.TextAreaField:
if validators is None:
@@ -190,4 +225,24 @@ def textarea(
if "render_kw" not in kwargs:
kwargs["render_kw"] = {}
kwargs["render_kw"]["placeholder"] = placeholder
+ if rows is not None:
+ if "render_kw" not in kwargs:
+ kwargs["render_kw"] = {}
+ kwargs["render_kw"]["rows"] = rows
return wtforms.TextAreaField(label, validators=validators, **kwargs)
+
+
+def url(
+ label: str,
+ optional: bool = False,
+ placeholder: str | None = None,
+ validators: list[Any] | None = None,
+ **kwargs: Any,
+) -> wtforms.URLField:
+ if validators is None:
+ validators = [wtforms.validators.URL()]
+ if optional is False:
+ validators.append(REQUIRED)
+ else:
+ validators.append(OPTIONAL)
+ return wtforms.URLField(label, validators=validators, **kwargs)
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index e9bc1d8..cc75493 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -46,6 +46,20 @@ import atr.user as user
import atr.util as util
+class AddOpenPGPKeyForm(forms.Typed):
+ public_key = forms.textarea(
+ "Public OpenPGP key",
+ placeholder="Paste your ASCII-armored public OpenPGP key here...",
+ description="Your public key should be in ASCII-armored format, starting with"
+ ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
+ )
+ selected_committees = forms.checkboxes(
+ "Associate key with committees",
+ description="Select the committees with which to associate your key.",
+ )
+ submit = forms.submit("Add OpenPGP key")
+
+
class AddSSHKeyForm(forms.Typed):
key = forms.textarea(
"SSH public key",
@@ -71,35 +85,40 @@ class UpdateCommitteeKeysForm(forms.Typed):
submit = forms.submit("Regenerate KEYS file")
+class UpdateKeyCommitteesForm(forms.Typed):
+ selected_committees = forms.multiple(
+ "Associated PMCs",
+ description="Select the committees associated with this key.",
+ )
+ submit = forms.submit("Update associations")
+
+
class UploadKeyFormBase(forms.Typed):
- key = wtforms.FileField(
+ key = forms.file(
"KEYS file",
- validators=[wtforms.validators.Optional()],
+ optional=True,
description=(
"Upload a KEYS file containing multiple PGP public keys."
" The file should contain keys in ASCII-armored format, starting with"
' "-----BEGIN PGP PUBLIC KEY BLOCK-----".'
),
)
- keys_url = wtforms.URLField(
+ keys_url = forms.url(
"KEYS file URL",
- validators=[wtforms.validators.Optional(), wtforms.validators.URL()],
- render_kw={"placeholder": "Enter URL to KEYS file"},
+ optional=True,
+ placeholder="Enter URL to KEYS file",
description="Enter a URL to a KEYS file. This will be fetched by the server.",
)
- submit = wtforms.SubmitField("Upload KEYS file")
- selected_committee = wtforms.SelectField(
- "Associate keys with committees",
- choices=[(c.name, c.display_name) for c in [] if (not util.committee_is_standing(c.name))],
- coerce=str,
- option_widget=wtforms.widgets.RadioInput(),
- widget=wtforms.widgets.ListWidget(prefix_label=False),
- validators=[wtforms.validators.InputRequired("You must select at least one committee")],
+
+ selected_committee = forms.radio(
+ "Associate keys with committee",
description=(
"Select the committee with which to associate these keys. You must be a member of the selected committee."
),
)
+ submit = forms.submit("Upload KEYS file")
+
async def validate(self, extra_validators: dict | None = None) -> bool:
"""Ensure that either a file is uploaded or a URL is provided, but not both."""
if not await super().validate(extra_validators):
@@ -129,30 +148,12 @@ async def add(session: routes.CommitterSession) -> str:
async with storage.write(session.uid) as write:
participant_of_committees = await write.participant_of_committees()
- committee_choices = [(c.name, c.display_name or c.name) for c in participant_of_committees]
-
- class AddOpenPGPKeyForm(forms.Typed):
- public_key = wtforms.TextAreaField(
- "Public OpenPGP key",
- validators=[wtforms.validators.InputRequired("Public key is required")],
- render_kw={"placeholder": "Paste your ASCII-armored public OpenPGP key here..."},
- description="Your public key should be in ASCII-armored format, starting with"
- ' "-----BEGIN PGP PUBLIC KEY BLOCK-----"',
- )
- selected_committees = wtforms.SelectMultipleField(
- "Associate key with committees",
- validators=[wtforms.validators.InputRequired("You must select at least one committee")],
- coerce=str,
- choices=committee_choices,
- option_widget=wtforms.widgets.CheckboxInput(),
- widget=wtforms.widgets.ListWidget(prefix_label=False),
- description="Select the committees with which to associate your key.",
- )
- submit = wtforms.SubmitField("Add OpenPGP key")
+ committee_choices: forms.Choices = [(c.name, c.display_name or c.name) for c in participant_of_committees]
form = await AddOpenPGPKeyForm.create_form(
data=await quart.request.form if quart.request.method == "POST" else None
)
+ forms.choices(form.selected_committees, committee_choices)
if await form.validate_on_submit():
try:
@@ -177,6 +178,7 @@ async def add(session: routes.CommitterSession) -> str:
await quart.flash(f"OpenPGP key {key.key_model.fingerprint.upper()} added successfully.", "success")
# Clear form data on success by creating a new empty form instance
form = await AddOpenPGPKeyForm.create_form()
+ forms.choices(form.selected_committees, committee_choices)
except routes.FlashError as e:
log.warning("FlashError adding OpenPGP key: %s", e)
@@ -241,25 +243,13 @@ async def details(session: routes.CommitterSession, fingerprint: str) -> str | r
if is_owner:
project_list = session.committees + session.projects
user_committees = await data.committee(name_in=project_list).all()
- committee_choices = [(c.name, c.display_name or c.name) for c in user_committees]
-
- class UpdateKeyCommitteesForm(forms.Typed):
- selected_committees = wtforms.SelectMultipleField(
- "Associated PMCs",
- coerce=str,
- choices=committee_choices,
- option_widget=wtforms.widgets.CheckboxInput(),
- widget=wtforms.widgets.ListWidget(prefix_label=False),
- description="Select the committees associated with this key.",
- )
- submit = wtforms.SubmitField("Update associations")
+ committee_choices: forms.Choices = [(c.name, c.display_name or c.name) for c in user_committees]
+ # TODO: Probably need to do data in a separate phase
form = await UpdateKeyCommitteesForm.create_form(
data=await quart.request.form if (quart.request.method == "POST") else None
)
-
- if quart.request.method == "GET":
- form.selected_committees.data = [c.name for c in key.committees]
+ forms.choices(form.selected_committees, committee_choices)
if form and await form.validate_on_submit():
async with db.session() as data:
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 1c70296..7012975 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -22,7 +22,6 @@ import aioshutil
import asfquart
import quart
import werkzeug.wrappers.response as response
-import wtforms
import atr.construct as construct
import atr.db as db
@@ -41,24 +40,18 @@ if asfquart.APP is ...:
class AnnouncePreviewForm(forms.Typed):
"""Form for validating preview request data."""
- subject = wtforms.StringField("Subject", validators=[wtforms.validators.Optional()])
- body = wtforms.TextAreaField("Body", validators=[wtforms.validators.InputRequired("Body is required for preview")])
+ subject = forms.optional("Subject")
+ body = forms.textarea("Body")
class DeleteForm(forms.Typed):
"""Form for deleting a release preview."""
- release_name = wtforms.HiddenField(validators=[wtforms.validators.InputRequired()])
- project_name = wtforms.HiddenField(validators=[wtforms.validators.InputRequired()])
- version_name = wtforms.HiddenField(validators=[wtforms.validators.InputRequired()])
- confirm_delete = wtforms.StringField(
- "Confirmation",
- validators=[
- wtforms.validators.InputRequired("Confirmation is required"),
- wtforms.validators.Regexp("^DELETE$", message="Please type DELETE to confirm"),
- ],
- )
- submit = wtforms.SubmitField("Delete preview")
+ 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 preview")
@routes.committer("/preview/announce/<project_name>/<version_name>", methods=["POST"])
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 3d2fbea..3ede1a5 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -20,7 +20,7 @@
import datetime
import http.client
import re
-from typing import Any, Final, Protocol
+from typing import Any, Final
import asfquart.base as base
import quart
@@ -42,17 +42,17 @@ _FORBIDDEN_CATEGORIES: Final[set[str]] = {
}
-class AddFormProtocol(Protocol):
- committee_name: wtforms.HiddenField
- display_name: wtforms.StringField
- label: wtforms.StringField
- submit: wtforms.SubmitField
+class AddForm(forms.Typed):
+ committee_name = forms.hidden()
+ display_name = forms.string("Display name")
+ label = forms.string("Label")
+ submit = forms.submit("Add project")
class ProjectMetadataForm(forms.Typed):
- project_name = wtforms.HiddenField(validators=[wtforms.validators.InputRequired()])
- category_to_add = wtforms.StringField("New category name")
- language_to_add = wtforms.StringField("New language name")
+ project_name = forms.hidden()
+ category_to_add = forms.string("New category name")
+ language_to_add = forms.string("New language name")
class ReleasePolicyForm(forms.Typed):
@@ -63,84 +63,60 @@ class ReleasePolicyForm(forms.Typed):
see: https://stackoverflow.com/questions/49066046/append-entry-to-fieldlist-with-flask-wtforms-using-ajax
"""
- project_name = wtforms.HiddenField("project_name")
+ project_name = forms.hidden()
# Compose section
- source_artifact_paths = wtforms.StringField(
- "Source artifact paths",
- widget=wtforms.widgets.TextArea(),
- render_kw={"rows": 5},
- description="Paths to source artifacts to be included in the release.",
+ source_artifact_paths = forms.textarea(
+ "Source artifact paths", rows=5, description="Paths to source artifacts to be included in the release."
)
- binary_artifact_paths = wtforms.StringField(
- "Binary artifact paths",
- widget=wtforms.widgets.TextArea(),
- render_kw={"rows": 5},
- description="Paths to binary artifacts to be included in the release.",
+ binary_artifact_paths = forms.textarea(
+ "Binary artifact paths", rows=5, description="Paths to binary artifacts to be included in the release."
)
- strict_checking = wtforms.BooleanField(
- "Strict checking",
- description="If enabled, then the release cannot be voted upon unless all checks pass.",
+ strict_checking = forms.boolean(
+ "Strict checking", description="If enabled, then the release cannot be voted upon unless all checks pass."
)
# Vote section
- manual_vote = wtforms.BooleanField(
+ manual_vote = forms.boolean(
"Manual voting process",
description="If this is set then the vote will be completely manual and following policy is ignored.",
- default=False,
)
- mailto_addresses = wtforms.FieldList(
- wtforms.StringField(
- "Email",
- validators=[
- wtforms.validators.InputRequired("Please provide a valid email address"),
- wtforms.validators.Email(),
- ],
- render_kw={"size": 30, "placeholder": "E.g. dev@project.apache.org"},
- description="Note: This field determines where vote and finished release announcement emails are sent."
- " You can set this value to your own mailing list, but ATR will currently only let you send to"
- " user-tests@tooling.apache.org.",
- ),
- min_entries=1,
+ mailto_addresses = forms.string(
+ "Email",
+ validators=[forms.REQUIRED, forms.EMAIL],
+ placeholder="E.g. dev@project.apache.org",
+ description="Note: This field determines where vote and finished release announcement"
+ "emails are sent. You can set this value to your own mailing list, but ATR will "
+ "currently only let you send to user-tests@tooling.apache.org.",
)
- default_min_hours_value_at_render = wtforms.HiddenField()
- min_hours = wtforms.IntegerField(
+ default_min_hours_value_at_render = forms.hidden()
+ min_hours = forms.integer(
"Minimum voting period",
- validators=[
- wtforms.validators.InputRequired("Please provide a minimum voting period"),
- util.validate_vote_duration,
- ],
+ validators=[util.validate_vote_duration],
default=72,
description="The minimum time to run the vote, in hours. Must be 0 or between 72 and 144 inclusive."
" If 0, then wait until 3 +1 votes and more +1 than -1.",
)
- pause_for_rm = wtforms.BooleanField(
+ pause_for_rm = forms.boolean(
"Pause for RM", description="If enabled, RM can confirm manually if the vote has passed."
)
- release_checklist = wtforms.StringField(
+ release_checklist = forms.textarea(
"Release checklist",
- widget=wtforms.widgets.TextArea(),
- render_kw={"rows": 10},
+ rows=10,
description="Markdown text describing how to test release candidates.",
)
- default_start_vote_template_hash = wtforms.HiddenField()
- start_vote_template = wtforms.StringField(
- "Start vote template",
- widget=wtforms.widgets.TextArea(),
- render_kw={"rows": 10},
- description="Email template for messages to start a vote on a release.",
+ default_start_vote_template_hash = forms.hidden()
+ start_vote_template = forms.textarea(
+ "Start vote template", rows=10, description="Email template for messages to start a vote on a release."
)
# Finish section
- default_announce_release_template_hash = wtforms.HiddenField()
- announce_release_template = wtforms.StringField(
- "Announce release template",
- widget=wtforms.widgets.TextArea(),
- render_kw={"rows": 10},
- description="Email template for messages to announce a finished release.",
+ default_announce_release_template_hash = forms.hidden()
+ announce_release_template = forms.textarea(
+ "Announce release template", rows=10, description="Email template for messages to announce a finished release."
)
- submit_policy = wtforms.SubmitField("Save")
+ submit_policy = forms.submit("Save")
async def validate(self, extra_validators: dict[str, Any] | None = None) -> bool:
await super().validate(extra_validators=extra_validators)
@@ -182,25 +158,15 @@ async def add_project(session: routes.CommitterSession, committee_name: str) ->
base.ASFQuartException(f"Committee {committee_name} not found", errorcode=404)
)
- class AddForm(forms.Typed):
- committee_name = wtforms.HiddenField("committee_name")
- display_name = wtforms.StringField(
- "Display name",
- description=f"""\
+ form = await AddForm.create_form(data={"committee_name": committee_name})
+ form.display_name.description = f"""\
For example, "Apache {committee.display_name}" or "Apache {committee.display_name} Components".
You must start with "Apache " and you must use title case.
-""",
- )
- label = wtforms.StringField(
- "Label",
- description=f"""\
+"""
+ form.label.description = f"""\
For example, "{committee.name}" or "{committee.name}-components".
You must start with your committee label, and you must use lower case.
-""",
- )
- submit = wtforms.SubmitField("Add project")
-
- form = await AddForm.create_form(data={"committee_name": committee_name})
+"""
if await form.validate_on_submit():
return await _project_add(form, session.uid)
@@ -486,7 +452,7 @@ async def _policy_edit(
# Vote section
release_policy.manual_vote = policy_form.manual_vote.data or False
if not release_policy.manual_vote:
- release_policy.mailto_addresses = [util.unwrap(policy_form.mailto_addresses.entries[0].data)]
+ release_policy.mailto_addresses = [util.unwrap(policy_form.mailto_addresses.data)]
_set_default_min_hours(policy_form, project, release_policy)
release_policy.pause_for_rm = util.unwrap(policy_form.pause_for_rm.data)
release_policy.release_checklist = util.unwrap(policy_form.release_checklist.data)
@@ -512,9 +478,9 @@ async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
policy_form = await ReleasePolicyForm.create_form()
policy_form.project_name.data = project.name
if project.policy_mailto_addresses:
- policy_form.mailto_addresses.entries[0].data = project.policy_mailto_addresses[0]
+ policy_form.mailto_addresses.data = project.policy_mailto_addresses[0]
else:
- policy_form.mailto_addresses.entries[0].data = f"dev@{project.name}.apache.org"
+ policy_form.mailto_addresses.data = f"dev@{project.name}.apache.org"
policy_form.min_hours.data = project.policy_min_hours
policy_form.manual_vote.data = project.policy_manual_vote
policy_form.release_checklist.data = project.policy_release_checklist
@@ -536,7 +502,7 @@ async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
return policy_form
-async def _project_add(form: AddFormProtocol, asf_id: str) -> response.Response:
+async def _project_add(form: AddForm, asf_id: str) -> response.Response:
form_values = await _project_add_validate(form)
if form_values is None:
return quart.redirect(util.as_url(add_project, committee_name=form.committee_name.data))
@@ -579,7 +545,7 @@ async def _project_add(form: AddFormProtocol, asf_id: str) -> response.Response:
return quart.redirect(util.as_url(view, name=label))
-async def _project_add_validate(form: AddFormProtocol) -> tuple[str, str, str] | None:
+async def _project_add_validate(form: AddForm) -> tuple[str, str, str] | None:
committee_name = str(form.committee_name.data)
# Normalise spaces in the display name, then validate
display_name = str(form.display_name.data).strip()
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 33c41ed..cfc6c4d 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -137,11 +137,11 @@
<div id="vote-options-extra">
<div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.mailto_addresses.entries[0], col="md3") }}
+ {{ forms.label(policy_form.mailto_addresses, col="md3") }}
<div class="col-sm-8">
- {{ forms.widget(policy_form.mailto_addresses.entries[0]) }}
- {{ forms.errors(policy_form.mailto_addresses.entries[0]) }}
- {{ forms.description(policy_form.mailto_addresses.entries[0]) }}
+ {{ forms.widget(policy_form.mailto_addresses) }}
+ {{ forms.errors(policy_form.mailto_addresses) }}
+ {{ forms.description(policy_form.mailto_addresses) }}
</div>
</div>
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org