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