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]