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 9672a90  Add tag forms to project pages
9672a90 is described below

commit 9672a9019a5b7af18916bf64583a5dfdd0c771b1
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri May 30 15:53:05 2025 +0100

    Add tag forms to project pages
---
 atr/routes/projects.py          | 214 ++++++++++++++++++++++++++++++----------
 atr/templates/project-view.html |  96 +++++++++++++++++-
 2 files changed, 254 insertions(+), 56 deletions(-)

diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 84676e1..debdf19 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -41,6 +41,12 @@ class AddFormProtocol(Protocol):
     submit: wtforms.SubmitField
 
 
+class ProjectMetadataForm(util.QuartFormTyped):
+    project_name = 
wtforms.HiddenField(validators=[wtforms.validators.InputRequired()])
+    category_to_add = wtforms.StringField("New category name")
+    language_to_add = wtforms.StringField("New language name")
+
+
 class ReleasePolicyForm(util.QuartFormTyped):
     """
     A Form to create or edit a ReleasePolicy.
@@ -104,7 +110,7 @@ class ReleasePolicyForm(util.QuartFormTyped):
         "Pause for RM", description="If enabled, RM can confirm manually if 
the vote has passed."
     )
 
-    submit = wtforms.SubmitField("Save")
+    submit_policy = wtforms.SubmitField("Save")
 
 
 @routes.committer("/project/add/<project_name>", methods=["GET", "POST"])
@@ -131,7 +137,7 @@ async def add_project(session: routes.CommitterSession, 
project_name: str) -> re
     form = await AddForm.create_form(data={"project_name": project_name})
 
     if await form.validate_on_submit():
-        return await _add_project(form, session.uid)
+        return await _project_add(form, session.uid)
 
     return await template.render("project-add-project.html", form=form, 
project_name=project.display_name)
 
@@ -191,8 +197,8 @@ async def select(session: routes.CommitterSession) -> str:
     """Select a project to work on."""
     user_projects = []
     if session.uid:
-        # TODO: Move this filtering logic somewhere else
         async with db.session() as data:
+            # TODO: Move this filtering logic somewhere else
             all_projects = await data.project(_committee=True).all()
             user_projects = [
                 p
@@ -212,20 +218,33 @@ async def select(session: routes.CommitterSession) -> str:
 @routes.committer("/projects/<name>", methods=["GET", "POST"])
 async def view(session: routes.CommitterSession, name: str) -> 
response.Response | str:
     policy_form = None
-    can_edit_policy = False
+    metadata_form = None
+    can_edit = False
+
     async with db.session() as data:
         project = await data.project(name=name, 
_committee_public_signing_keys=True, _release_policy=True).demand(
             http.client.HTTPException(404)
         )
 
-        if project.committee and session.uid:
-            can_edit_policy = user.is_committee_member(project.committee, 
session.uid) or user.is_admin(session.uid)
-
-        if can_edit_policy:
-            edited, policy_form = await _edit_policy(data, policy_form, 
project)
-            if edited is True:
-                await quart.flash("Release policy updated successfully.", 
"success")
-                return quart.redirect(util.as_url(view, name=project.name))
+        is_committee_member = project.committee and 
(user.is_committee_member(project.committee, session.uid))
+        is_privileged = user.is_admin(session.uid)
+        can_edit = is_committee_member or is_privileged
+
+        if can_edit and (quart.request.method == "POST"):
+            form_data = await quart.request.form
+            if "submit_metadata" in form_data:
+                edited_metadata, metadata_form = await _metadata_edit(data, 
project, form_data)
+                if edited_metadata is True:
+                    return quart.redirect(util.as_url(view, name=project.name))
+            elif "submit_policy" in form_data:
+                edited_policy, policy_form = await _policy_edit(data, project, 
form_data)
+                if edited_policy:
+                    return quart.redirect(util.as_url(view, name=project.name))
+
+        if metadata_form is None:
+            metadata_form = await 
ProjectMetadataForm.create_form(data={"project_name": project.name})
+        if policy_form is None:
+            policy_form = await _policy_form_create(project)
 
     return await template.render(
         "project-view.html",
@@ -239,57 +258,144 @@ async def view(session: routes.CommitterSession, name: 
str) -> response.Response
         now=datetime.datetime.now(datetime.UTC),
         empty_form=await util.EmptyForm.create_form(),
         policy_form=policy_form,
-        can_edit_policy=can_edit_policy,
+        can_edit=can_edit,
+        metadata_form=metadata_form,
     )
 
 
-async def _edit_policy(
-    data: db.Session, policy_form: ReleasePolicyForm | None, project: 
models.Project
-) -> tuple[bool, ReleasePolicyForm | None]:
-    if quart.request.method == "POST":
-        policy_form = await ReleasePolicyForm.create_form(data=await 
quart.request.form)
-        if await policy_form.validate_on_submit():
-            release_policy = project.release_policy
-            if release_policy is None:
-                release_policy = models.ReleasePolicy(project=project)
-                project.release_policy = release_policy
-                data.add(release_policy)
-
-            release_policy.mailto_addresses = 
[util.unwrap(policy_form.mailto_addresses.entries[0].data)]
-            release_policy.manual_vote = 
util.unwrap(policy_form.manual_vote.data)
-            release_policy.release_checklist = 
util.unwrap(policy_form.release_checklist.data)
-            _set_default_fields(policy_form, project, release_policy)
-
-            release_policy.pause_for_rm = 
util.unwrap(policy_form.pause_for_rm.data)
-            await data.commit()
-            return True, None
+async def _metadata_category_edit(
+    metadata_form: ProjectMetadataForm,
+    project: models.Project,
+    action_type: str,
+    action_value: str,
+    current_categories: list[str],
+    current_languages: list[str],
+) -> bool:
+    # TODO: Add error handling
+    # Also this only just squeaks by the complexity checker
+    modified = False
+    if (action_type == "add_category") and metadata_form.category_to_add.data:
+        new_cat = metadata_form.category_to_add.data.strip()
+        if new_cat and (new_cat not in current_categories):
+            if ":" in new_cat:
+                raise ValueError(f"Category '{new_cat}' contains a colon")
+            current_categories.append(new_cat)
+            current_categories.sort()
+            project.category = ", ".join(current_categories)
+            await quart.flash(f"Category '{new_cat}' added.", "success")
+            modified = True
+    elif (action_type == "remove_category") and action_value and (action_value 
in current_categories):
+        current_categories.remove(action_value)
+        project.category = ", ".join(current_categories)
+        await quart.flash(f"Category '{action_value}' removed.", "success")
+        modified = True
+    elif (action_type == "add_language") and 
metadata_form.language_to_add.data:
+        new_lang = metadata_form.language_to_add.data.strip()
+        if new_lang and (new_lang not in current_languages):
+            if ":" in new_lang:
+                raise ValueError(f"Language '{new_lang}' contains a colon")
+            current_languages.append(new_lang)
+            current_languages.sort()
+            project.programming_languages = ", ".join(current_languages)
+            await quart.flash(f"Language '{new_lang}' added.", "success")
+            modified = True
+    elif (action_type == "remove_language") and action_value and (action_value 
in current_languages):
+        current_languages.remove(action_value)
+        project.programming_languages = ", ".join(current_languages)
+        await quart.flash(f"Language '{action_value}' removed.", "success")
+        modified = True
+    return modified
+
+
+async def _metadata_edit(
+    data: db.Session, project: models.Project, form_data: dict[str, str]
+) -> tuple[bool, ProjectMetadataForm]:
+    metadata_form = await ProjectMetadataForm.create_form(data=form_data)
+
+    if await metadata_form.validate_on_submit():
+        current_categories = (
+            [category.strip() for category in (project.category or 
"").split(",") if category.strip()]
+            if project.category
+            else []
+        )
+        current_languages = (
+            [language.strip() for language in (project.programming_languages 
or "").split(",") if language.strip()]
+            if project.programming_languages
+            else []
+        )
 
-    if policy_form is None:
-        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]
+        form_data = await quart.request.form
+        action_full = form_data.get("action", "")
+        action_type = ""
+        action_value = ""
+        if ":" in action_full:
+            action_type, action_value = action_full.split(":", 1)
         else:
-            policy_form.mailto_addresses.entries[0].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
-        policy_form.start_vote_template.data = 
project.policy_start_vote_template
-        policy_form.announce_release_template.data = 
project.policy_announce_release_template
-        policy_form.pause_for_rm.data = project.policy_pause_for_rm
-
-        # Set the hashes and value of the current defaults
-        policy_form.default_start_vote_template_hash.data = 
util.compute_sha3_256(
-            project.policy_start_vote_default.encode()
-        )
-        policy_form.default_announce_release_template_hash.data = 
util.compute_sha3_256(
-            project.policy_announce_release_default.encode()
+            action_type = action_full
+
+        modified = await _metadata_category_edit(
+            metadata_form, project, action_type, action_value, 
current_categories, current_languages
         )
-        policy_form.default_min_hours_value_at_render.data = 
str(project.policy_default_min_hours)
+
+        if modified:
+            if project.category == "":
+                project.category = None
+            if project.programming_languages == "":
+                project.programming_languages = None
+            await data.commit()
+            return True, metadata_form
+    return False, metadata_form
+
+
+async def _policy_edit(
+    data: db.Session, project: models.Project, form_data: dict[str, str]
+) -> tuple[bool, ReleasePolicyForm]:
+    policy_form = await ReleasePolicyForm.create_form(data=form_data)
+    if await policy_form.validate_on_submit():
+        release_policy = project.release_policy
+        if release_policy is None:
+            release_policy = models.ReleasePolicy(project=project)
+            project.release_policy = release_policy
+            data.add(release_policy)
+
+        release_policy.mailto_addresses = 
[util.unwrap(policy_form.mailto_addresses.entries[0].data)]
+        release_policy.manual_vote = util.unwrap(policy_form.manual_vote.data)
+        release_policy.release_checklist = 
util.unwrap(policy_form.release_checklist.data)
+        _set_default_fields(policy_form, project, release_policy)
+
+        release_policy.pause_for_rm = 
util.unwrap(policy_form.pause_for_rm.data)
+        await data.commit()
+        await quart.flash("Release policy updated successfully.", "success")
+        return True, policy_form
     return False, policy_form
 
 
-async def _add_project(form: AddFormProtocol, asf_id: str) -> 
response.Response:
+async def _policy_form_create(project: models.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]
+    else:
+        policy_form.mailto_addresses.entries[0].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
+    policy_form.start_vote_template.data = project.policy_start_vote_template
+    policy_form.announce_release_template.data = 
project.policy_announce_release_template
+    policy_form.pause_for_rm.data = project.policy_pause_for_rm
+
+    # Set the hashes and value of the current defaults
+    policy_form.default_start_vote_template_hash.data = util.compute_sha3_256(
+        project.policy_start_vote_default.encode()
+    )
+    policy_form.default_announce_release_template_hash.data = 
util.compute_sha3_256(
+        project.policy_announce_release_default.encode()
+    )
+    policy_form.default_min_hours_value_at_render.data = 
str(project.policy_default_min_hours)
+    return policy_form
+
+
+async def _project_add(form: AddFormProtocol, asf_id: str) -> 
response.Response:
     base_project_name = str(form.project_name.data)
     derived_project_name = str(form.derived_project_name.data).strip()
 
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 1a75a4f..5b21567 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -8,6 +8,17 @@
   Information regarding an Apache Project.
 {% endblock description %}
 
+{% block stylesheets %}
+  {{ super() }}
+  <style>
+      .page-remove-tag {
+          font-size: 0.65em;
+          padding: 0.2em 0.3em;
+          cursor: pointer;
+      }
+  </style>
+{% endblock stylesheets %}
+
 {% block content %}
   <div class="row">
     <div class="col-md">
@@ -59,7 +70,7 @@
       <h3 class="mb-0">Release policy</h3>
     </div>
     <div class="card-body">
-      {% if can_edit_policy and policy_form %}
+      {% if can_edit and policy_form %}
         <form method="post"
               action="{{ as_url(routes.projects.view, name=project.name) }}"
               class="atr-canary py-4 px-5"
@@ -138,7 +149,7 @@
           </div>
 
           <div class="row">
-            <div class="col-sm-9 offset-sm-3">{{ 
policy_form.submit(class_="btn btn-primary mt-2") }}</div>
+            <div class="col-sm-9 offset-sm-3">{{ 
policy_form.submit_policy(class_="btn btn-primary mt-2") }}</div>
           </div>
         </form>
       {% elif project.release_policy or project.name %}
@@ -309,6 +320,87 @@
       </div>
     {% endif %}
 
+    {% if can_edit and metadata_form %}
+      <div class="card mb-4">
+        <div class="card-header bg-light">
+          <h3 class="mb-2">Categories</h3>
+        </div>
+        <div class="card-body">
+          <form method="post"
+                action="{{ as_url(routes.projects.view, name=project.name) }}"
+                class="mb-3">
+            {{ metadata_form.hidden_tag() if metadata_form.hidden_tag }}
+            {{ metadata_form.project_name() }}
+            <input type="hidden" name="submit_metadata" value="true" />
+
+            <div class="d-flex align-items-center mb-3">
+              {{ forms.widget(metadata_form.category_to_add, 
classes="form-control form-control-sm me-2", placeholder="New category") }}
+              <button type="submit"
+                      name="action"
+                      value="add_category"
+                      class="btn btn-sm btn-success text-nowrap pe-3">
+                <i class="bi bi-plus"></i> Add
+              </button>
+            </div>
+            {{ forms.errors(metadata_form.category_to_add) }}
+
+            <div class="d-flex flex-wrap gap-2 align-items-center">
+              {% set current_categories = project.category.split(", ") if 
project.category else [] %}
+              {% for cat in current_categories %}
+                <div class="badge bg-primary d-flex align-items-center p-2">
+                  <span>{{ cat }}</span>
+                  <button type="submit"
+                          name="action"
+                          value="remove_category:{{ cat }}"
+                          class="btn-close btn-close-white ms-2 
page-remove-tag"
+                          aria-label="Remove {{ cat }}"></button>
+                </div>
+              {% endfor %}
+            </div>
+          </form>
+        </div>
+      </div>
+
+      <div class="card mb-4">
+        <div class="card-header bg-light">
+          <h3 class="mb-2">Programming languages</h3>
+        </div>
+        <div class="card-body">
+          <form method="post"
+                action="{{ as_url(routes.projects.view, name=project.name) }}"
+                class="mb-3">
+            {{ metadata_form.hidden_tag() if metadata_form.hidden_tag }}
+            {{ metadata_form.project_name() }}
+            <input type="hidden" name="submit_metadata" value="true" />
+
+            <div class="d-flex align-items-center mb-3">
+              {{ forms.widget(metadata_form.language_to_add, 
classes="form-control form-control-sm me-2", placeholder="New language") }}
+              <button type="submit"
+                      name="action"
+                      value="add_language"
+                      class="btn btn-sm btn-success text-nowrap pe-3">
+                <i class="bi bi-plus"></i> Add
+              </button>
+            </div>
+            {{ forms.errors(metadata_form.language_to_add) }}
+
+            <div class="d-flex flex-wrap gap-2 align-items-center">
+              {% set current_languages = 
project.programming_languages.split(", ") if project.programming_languages else 
[] %}
+              {% for lang in current_languages %}
+                <div class="badge bg-success d-flex align-items-center p-2">
+                  <span>{{ lang }}</span>
+                  <button type="submit"
+                          name="action"
+                          value="remove_language:{{ lang }}"
+                          class="btn-close btn-close-white ms-2 
page-remove-tag"
+                          aria-label="Remove {{ lang }}"></button>
+                </div>
+              {% endfor %}
+            </div>
+          </form>
+        </div>
+      </div>
+    {% endif %}
   {% endblock content %}
 
   {% block javascripts %}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to