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 a43913c Make the forms to update release policies more type safe
a43913c is described below
commit a43913c9fc39c72d5699fedbff7106e64e3488e0
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Nov 14 16:54:31 2025 +0000
Make the forms to update release policies more type safe
---
atr/get/committees.py | 5 +-
atr/get/projects.py | 434 +++++++++++++++++++++++++++++++-
atr/post/projects.py | 304 ++++++++++++++++++++++-
atr/shared/projects.py | 496 +++++++++++-------------------------
atr/templates/project-view.html | 538 ----------------------------------------
5 files changed, 887 insertions(+), 890 deletions(-)
diff --git a/atr/get/committees.py b/atr/get/committees.py
index 8f8107b..15b6ffb 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -16,7 +16,8 @@
# under the License.
import datetime
-import http.client
+
+import asfquart.base as base
import atr.blueprints.get as get
import atr.db as db
@@ -52,7 +53,7 @@ async def view(session: web.Committer | None, name: str) ->
str:
name=name,
_projects=True,
_public_signing_keys=True,
- ).demand(http.client.HTTPException(404))
+ ).demand(base.ASFQuartException(f"Committee {name} not found",
errorcode=404))
project_list = list(committee.projects)
for project in project_list:
# Workaround for the usual loading problem
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 2e4dec1..eedd393 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -17,14 +17,30 @@
from __future__ import annotations
+import asfquart.base as base
+import htpy
+
import atr.blueprints.get as get
import atr.config as config
import atr.db as db
+import atr.db.interaction as interaction
+import atr.form as form
import atr.forms as forms
+import atr.get.committees as committees
+import atr.get.draft as draft
+import atr.get.preview as preview
+import atr.get.release as release
+import atr.get.start as start
+import atr.htm as htm
import atr.models.sql as sql
+import atr.post as post
+import atr.registry as registry
import atr.shared as shared
import atr.template as template
+import atr.user as user
+import atr.util as util
import atr.web as web
+from atr.get import candidate
@get.committer("/project/add/<committee_name>")
@@ -68,4 +84,420 @@ async def select(session: web.Committer) -> str:
@get.committer("/projects/<name>")
async def view(session: web.Committer, name: str) -> web.WerkzeugResponse |
str:
- return await shared.projects.view(session, name)
+ async with db.session() as data:
+ project = await data.project(
+ name=name, _committee=True, _committee_public_signing_keys=True,
_release_policy=True
+ ).demand(base.ASFQuartException(f"Project {name} not found",
errorcode=404))
+
+ 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
+
+ candidate_drafts = await interaction.candidate_drafts(project)
+ candidates = await interaction.candidates(project)
+ previews = await interaction.previews(project)
+ full_releases = await interaction.full_releases(project)
+
+ page = htm.Block()
+
+ page_styles = """
+ .page-remove-tag {
+ font-size: 0.65em;
+ padding: 0.2em 0.3em;
+ cursor: pointer;
+ }
+ """
+ page.style[page_styles]
+
+ title_row = htm.div(".row")[
+ htm.div(".col-md")[htm.h1[project.display_name]],
+
htm.div(".col-sm-auto")[htm.span(".badge.text-bg-secondary")[project.status.value.lower()]]
+ if project.status.value.lower() != "active"
+ else "",
+ ]
+ page.append(title_row)
+
+ page.p(".mb-4")[
+ htm.a(".btn.btn-sm.btn-outline-primary",
href=util.as_url(start.selected, project_name=project.name))[
+ "Start a new release"
+ ]
+ ]
+
+ page.append(_render_project_label_card(project))
+ page.append(_render_pmc_card(project))
+ page.append(_render_description_card(project))
+
+ if project.status == sql.ProjectStatus.ACTIVE:
+ if can_edit:
+ page.append(_render_compose_form(project))
+ page.append(_render_vote_form(project))
+ page.append(_render_finish_form(project))
+ else:
+ page.append(_render_policy_readonly(project))
+
+ if can_edit:
+ page.append(_render_categories_section(project))
+ page.append(_render_languages_section(project))
+
+ if is_committee_member or is_privileged:
+ page.append(await _render_releases_sections(project, candidate_drafts,
candidates, previews, full_releases))
+
+ if project.created_by == session.uid:
+ page.append(_render_delete_section(project))
+
+ if is_committee_member or is_privileged:
+ if project.committee:
+ page.p[
+ htm.a(
+ ".btn.btn-sm.btn-outline-primary",
+ href=util.as_url(add_project,
committee_name=project.committee.name),
+ )["Create a sibling project"]
+ ]
+
+ content = page.collect()
+
+ return await template.blank(
+ title=f"{project.display_name}",
+ description=f"Information regarding {project.display_name}.",
+ content=content,
+ )
+
+
+def _render_categories_section(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-2")["Categories"]]
+
+ current_categories = project.category.split(", ") if project.category else
[]
+ category_badges = []
+ for cat in current_categories:
+ remove_button = (
+ # Manual form as badges are not handled by the form system
+ htm.form(".d-inline.m-0", method="post",
action=util.as_url(post.projects.view, name=project.name))[
+ form.csrf_input(),
+ htpy.input(type="hidden", name="project_name",
value=project.name),
+ htpy.input(type="hidden", name="variant",
value="remove_category"),
+ htpy.input(type="hidden", name="category_to_remove",
value=cat),
+ htpy.button(
+ ".btn-close.btn-close-white.ms-1.page-remove-tag",
type="submit", aria_label=f"Remove {cat}"
+ ),
+ ]
+ if cat not in registry.FORBIDDEN_PROJECT_CATEGORIES
+ else ""
+ )
+ badge =
htm.div(".badge.bg-primary.d-inline-flex.align-items-center.px-2.py-1")[
+ htm.span[cat],
+ remove_button,
+ ]
+ category_badges.append(badge)
+
+ add_form = htm.form(".mb-3", method="post",
action=util.as_url(post.projects.view, name=project.name))[
+ form.csrf_input(),
+ htpy.input(type="hidden", name="project_name", value=project.name),
+ htpy.input(type="hidden", name="variant", value="add_category"),
+ htm.div(".d-flex.align-items-center")[
+ htpy.input(
+ ".form-control.form-control-sm.me-2", type="text",
name="category_to_add", placeholder="New category"
+ ),
+ htpy.button(".btn.btn-sm.btn-success.text-nowrap.pe-3",
type="submit")[htpy.i(".bi.bi-plus"), " Add"],
+ ],
+ ]
+
+ with card.block(htm.div, classes=".card-body") as card_body:
+ card_body.append(add_form)
+ if category_badges:
+
card_body.append(htm.div(".d-flex.flex-wrap.gap-2.align-items-center.mt-3")[*category_badges])
+ return card.collect()
+
+
+def _render_compose_form(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
+ htm.h3(".mb-0")["Release policy - Compose options"]
+ ]
+
+ with card.block(htm.div, classes=".card-body") as card_body:
+ form.render_block(
+ card_body,
+ model_cls=shared.projects.ComposePolicyForm,
+ action=util.as_url(post.projects.view, name=project.name),
+ submit_label="Save",
+ defaults={
+ "project_name": project.name,
+ "source_artifact_paths":
"\n".join(project.policy_source_artifact_paths),
+ "binary_artifact_paths":
"\n".join(project.policy_binary_artifact_paths),
+ "github_repository_name":
project.policy_github_repository_name or "",
+ "github_compose_workflow_path":
"\n".join(project.policy_github_compose_workflow_path),
+ "strict_checking": project.policy_strict_checking,
+ },
+ form_classes=".atr-canary.py-4.px-5",
+ border=True,
+ # wider_widgets=True,
+ textarea_rows=5,
+ )
+ return card.collect()
+
+
+def _render_delete_section(project: sql.Project) -> htm.Element:
+ section = htm.Block(htm.div)
+ section.h2["Actions"]
+
+ delete_form = htm.form(
+ ".d-inline-block.m-0",
+ method="post",
+ action=util.as_url(post.projects.view, name=project.name),
+ onsubmit=(
+ f"return confirm('Are you sure you want to delete the project "
+ f"\\'{project.display_name}\\'? This cannot be undone.');"
+ ),
+ )[
+ form.csrf_input(),
+ htpy.input(type="hidden", name="project_name", value=project.name),
+ htpy.input(type="hidden", name="variant", value="delete_project"),
+ htpy.button(".btn.btn-sm.btn-outline-danger", type="submit",
title=f"Delete {project.display_name}")[
+ htpy.i(".bi.bi-trash"), " Delete project"
+ ],
+ ]
+
+ section.div(".my-3")[delete_form]
+ return section.collect()
+
+
+def _render_description_card(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-2")["Description"]]
+
card.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")[htm.span(".fs-6")[project.description]]]
+ return card.collect()
+
+
+def _render_finish_form(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
+ htm.h3(".mb-0")["Release policy - Finish options"]
+ ]
+
+ with card.block(htm.div, classes=".card-body") as card_body:
+ form.render_block(
+ card_body,
+ model_cls=shared.projects.FinishPolicyForm,
+ action=util.as_url(post.projects.view, name=project.name),
+ submit_label="Save",
+ defaults={
+ "project_name": project.name,
+ "github_finish_workflow_path":
"\n".join(project.policy_github_finish_workflow_path),
+ "announce_release_template":
project.policy_announce_release_template or "",
+ "preserve_download_files":
project.policy_preserve_download_files,
+ },
+ form_classes=".atr-canary.py-4.px-5",
+ border=True,
+ # wider_widgets=True,
+ textarea_rows=10,
+ )
+ return card.collect()
+
+
+def _render_languages_section(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-2")["Programming languages"]]
+
+ current_languages = project.programming_languages.split(", ") if
project.programming_languages else []
+ language_badges = []
+ for lang in current_languages:
+ # Manual form as badges are not handled by the form system
+ remove_button = htm.form(
+ ".d-inline.m-0", method="post",
action=util.as_url(post.projects.view, name=project.name)
+ )[
+ form.csrf_input(),
+ htpy.input(type="hidden", name="project_name", value=project.name),
+ htpy.input(type="hidden", name="variant", value="remove_language"),
+ htpy.input(type="hidden", name="language_to_remove", value=lang),
+ htpy.button(".btn-close.btn-close-white.ms-1.page-remove-tag",
type="submit", aria_label=f"Remove {lang}"),
+ ]
+ badge =
htm.div(".badge.bg-success.d-inline-flex.align-items-center.px-2.py-1")[
+ htm.span[lang],
+ remove_button,
+ ]
+ language_badges.append(badge)
+
+ add_form = htm.form(".mb-3", method="post",
action=util.as_url(post.projects.view, name=project.name))[
+ form.csrf_input(),
+ htpy.input(type="hidden", name="project_name", value=project.name),
+ htpy.input(type="hidden", name="variant", value="add_language"),
+ htm.div(".d-flex.align-items-center")[
+ htpy.input(
+ ".form-control.form-control-sm.me-2", type="text",
name="language_to_add", placeholder="New language"
+ ),
+ htpy.button(".btn.btn-sm.btn-success.text-nowrap.pe-3",
type="submit")[htpy.i(".bi.bi-plus"), " Add"],
+ ],
+ ]
+
+ with card.block(htm.div, classes=".card-body") as card_body:
+ card_body.append(add_form)
+ if language_badges:
+
card_body.append(htm.div(".d-flex.flex-wrap.gap-2.align-items-center.mt-3")[*language_badges])
+ return card.collect()
+
+
+def _render_pmc_card(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-2")["PMC"]]
+ if project.committee:
+ committee_link = htm.a(href=util.as_url(committees.view,
name=project.committee.name))[
+ project.committee.display_name
+ ]
+
card.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")[committee_link]]
+ else:
+
card.div(".card-body")[htm.div(".d-flex.flex-wrap.gap-3.small.mb-1")["No
committee"]]
+ return card.collect()
+
+
+def _render_policy_readonly(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-2")["Release policy"]]
+
+ email_content = (
+
htm.a(href=f"mailto:{project.policy_mailto_addresses[0]}")[project.policy_mailto_addresses[0]]
+ if project.policy_mailto_addresses
+ else "Not set"
+ )
+
+ tbody = htm.tbody[
+ htm.tr[
+ htm.th(".border-0.w-25")["Email"],
+ htm.td(".text-break.border-0")[email_content],
+ ],
+ htm.tr[
+ htm.th(".border-0")["Manual vote process"],
+ htm.td(".text-break.border-0")[str(project.policy_manual_vote)],
+ ],
+ htm.tr[
+ htm.th(".border-0")["Minimum voting period"],
+ htm.td(".text-break.border-0")[f"{project.policy_min_hours}h"],
+ ],
+ ]
+
+
card.div(".card-body")[htm.div(".card.h-100.border")[htm.div(".card-body")[htm.table(".table.mb-0")[tbody]]]]
+ return card.collect()
+
+
+def _render_project_label_card(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-2")["Project label"]]
+ card.div(".card-body")[htm.code(".fs-6")[project.name]]
+ return card.collect()
+
+
+async def _render_releases_sections(
+ project: sql.Project,
+ candidate_drafts: list[sql.Release],
+ candidates: list[sql.Release],
+ previews: list[sql.Release],
+ full_releases: list[sql.Release],
+) -> htm.Element:
+ sections = htm.Block(htm.div)
+
+ if candidate_drafts:
+ sections.h2["Draft candidate releases"]
+ draft_buttons = []
+ for drf in candidate_drafts:
+ file_count = await util.number_of_release_files(drf)
+ draft_buttons.append(
+ htm.a(
+ ".btn.btn-sm.btn-outline-secondary.py-2.px-3",
+ href=util.as_url(draft.view, project_name=project.name,
version_name=drf.version),
+ title=f"View draft {project.name} {drf.version}",
+ )[
+ f"{project.name} {drf.version} ",
+ htm.span(".badge.bg-secondary.ms-2")[f"{file_count}
{'file' if (file_count == 1) else 'files'}"],
+ ]
+ )
+ sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*draft_buttons]
+
+ if candidates:
+ sections.h2["Candidate releases"]
+ candidate_buttons = []
+ for cnd in candidates:
+ file_count = await util.number_of_release_files(cnd)
+ candidate_buttons.append(
+ htm.a(
+ ".btn.btn-sm.btn-outline-info.py-2.px-3",
+ href=util.as_url(candidate.view,
project_name=project.name, version_name=cnd.version),
+ title=f"View candidate {project.name} {cnd.version}",
+ )[
+ f"{project.name} {cnd.version} ",
+ htm.span(".badge.bg-info.ms-2")[f"{file_count} {'file' if
(file_count == 1) else 'files'}"],
+ ]
+ )
+ sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*candidate_buttons]
+
+ if previews:
+ sections.h2["Preview releases"]
+ preview_buttons = []
+ for prv in previews:
+ file_count = await util.number_of_release_files(prv)
+ preview_buttons.append(
+ htm.a(
+ ".btn.btn-sm.btn-outline-warning.py-2.px-3",
+ href=util.as_url(preview.view, project_name=project.name,
version_name=prv.version),
+ title=f"View preview {project.name} {prv.version}",
+ )[
+ f"{project.name} {prv.version} ",
+ htm.span(".badge.bg-warning.ms-2")[f"{file_count} {'file'
if (file_count == 1) else 'files'}"],
+ ]
+ )
+ sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*preview_buttons]
+
+ if full_releases:
+ sections.h2["Full releases"]
+ release_buttons = []
+ for rel in full_releases:
+ file_count = await util.number_of_release_files(rel)
+ release_buttons.append(
+ htm.a(
+ ".btn.btn-sm.btn-outline-success.py-2.px-3",
+ href=util.as_url(release.view, project_name=project.name,
version_name=rel.version),
+ title=f"View release {project.name} {rel.version}",
+ )[
+ f"{project.name} {rel.version} ",
+ htm.span(".badge.bg-success.ms-2")[f"{file_count} {'file'
if (file_count == 1) else 'files'}"],
+ ]
+ )
+ sections.div(".d-flex.flex-wrap.gap-2.mb-4")[*release_buttons]
+
+ return sections.collect()
+
+
+def _render_vote_form(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+
card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
+ htm.h3(".mb-0")["Release policy - Vote options"]
+ ]
+
+ defaults_dict = {
+ "project_name": project.name,
+ "github_vote_workflow_path":
"\n".join(project.policy_github_vote_workflow_path),
+ "mailto_addresses": project.policy_mailto_addresses[0]
+ if project.policy_mailto_addresses
+ else f"dev@{project.name}.apache.org",
+ "manual_vote": project.policy_manual_vote,
+ "min_hours": project.policy_min_hours,
+ "pause_for_rm": project.policy_pause_for_rm,
+ "release_checklist": project.policy_release_checklist or "",
+ "start_vote_template": project.policy_start_vote_template or "",
+ }
+
+ skip_fields = ["manual_vote"] if (project.committee and
project.committee.is_podling) else []
+
+ with card.block(htm.div, classes=".card-body") as card_body:
+ form.render_block(
+ card_body,
+ model_cls=shared.projects.VotePolicyForm,
+ action=util.as_url(post.projects.view, name=project.name),
+ submit_label="Save",
+ defaults=defaults_dict,
+ form_classes=".atr-canary.py-4.px-5",
+ border=True,
+ # wider_widgets=True,
+ textarea_rows=10,
+ skip=skip_fields,
+ )
+ return card.collect()
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 5ce94c1..80d437b 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -17,10 +17,14 @@
from __future__ import annotations
+import asfquart.base as base
import quart
import atr.blueprints.post as post
+import atr.db as db
import atr.get as get
+import atr.models.policy as policy
+import atr.models.sql as sql
import atr.shared as shared
import atr.storage as storage
import atr.util as util
@@ -55,5 +59,301 @@ async def delete(session: web.Committer) ->
web.WerkzeugResponse:
@post.committer("/projects/<name>")
-async def view(session: web.Committer, name: str) -> web.WerkzeugResponse |
str:
- return await shared.projects.view(session, name)
[email protected](shared.projects.ProjectViewForm)
+async def view(
+ session: web.Committer, project_form: shared.projects.ProjectViewForm,
name: str
+) -> web.WerkzeugResponse:
+ match project_form:
+ case shared.projects.AddCategoryForm() as add_category_form:
+ return await _process_add_category(session, add_category_form)
+
+ case shared.projects.AddLanguageForm() as add_language_form:
+ return await _process_add_language(session, add_language_form)
+
+ case shared.projects.ComposePolicyForm() as compose_form:
+ return await _process_compose_form(session, compose_form)
+
+ case shared.projects.DeleteProjectForm() as delete_form:
+ return await _process_delete_project(session, delete_form)
+
+ case shared.projects.FinishPolicyForm() as finish_form:
+ return await _process_finish_form(session, finish_form)
+
+ case shared.projects.RemoveCategoryForm() as remove_form:
+ return await _process_remove_category(session, remove_form)
+
+ case shared.projects.RemoveLanguageForm() as remove_form:
+ return await _process_remove_language(session, remove_form)
+
+ case shared.projects.VotePolicyForm() as vote_form:
+ return await _process_vote_form(session, vote_form)
+
+
+async def _metadata_category_add(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project,
category_to_add: str
+) -> bool:
+ try:
+ return await wacm.project.category_add(project,
category_to_add.strip())
+ except storage.AccessError as e:
+ await quart.flash(f"Error adding category: {e}", "error")
+ return False
+
+
+async def _metadata_category_remove(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
+) -> bool:
+ try:
+ return await wacm.project.category_remove(project, action_value)
+ except storage.AccessError as e:
+ await quart.flash(f"Error removing category: {e}", "error")
+ return False
+
+
+async def _metadata_language_add(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project,
language_to_add: str
+) -> bool:
+ try:
+ return await wacm.project.language_add(project, language_to_add)
+ except storage.AccessError as e:
+ await quart.flash(f"Error adding language: {e}", "error")
+ return False
+
+
+async def _metadata_language_remove(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
+) -> bool:
+ try:
+ return await wacm.project.language_remove(project, action_value)
+ except storage.AccessError as e:
+ await quart.flash(f"Error removing language: {e}", "error")
+ return False
+
+
+async def _process_add_category(
+ session: web.Committer, add_category_form: shared.projects.AddCategoryForm
+) -> web.WerkzeugResponse:
+ project_name = add_category_form.project_name
+ category_to_add = add_category_form.category_to_add.strip()
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ async with db.session() as data:
+ project = await data.project(name=project_name).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+ modified = await _metadata_category_add(wacm, project, category_to_add)
+
+ if modified:
+ return await session.redirect(
+ get.projects.view, name=project_name, success=f"Category
'{category_to_add}' added."
+ )
+ return await session.redirect(
+ get.projects.view, name=project_name, error=f"Category
'{category_to_add}' already exists."
+ )
+
+
+async def _process_add_language(
+ session: web.Committer, add_language_form: shared.projects.AddLanguageForm
+) -> web.WerkzeugResponse:
+ project_name = add_language_form.project_name
+ language_to_add = add_language_form.language_to_add.strip()
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ async with db.session() as data:
+ project = await data.project(name=project_name).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+ modified = await _metadata_language_add(wacm, project, language_to_add)
+
+ if modified:
+ return await session.redirect(
+ get.projects.view, name=project_name, success=f"Language
'{language_to_add}' added."
+ )
+ return await session.redirect(
+ get.projects.view, name=project_name, error=f"Language
'{language_to_add}' already exists."
+ )
+
+
+async def _process_compose_form(
+ session: web.Committer, compose_form: shared.projects.ComposePolicyForm
+) -> web.WerkzeugResponse:
+ project_name = compose_form.project_name
+
+ async with db.session() as data:
+ project = await data.project(name=project_name, _committee=True,
_release_policy=True).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+
+ policy_data = policy.ReleasePolicyData(
+ project_name=project_name,
+ source_artifact_paths=[p.strip() for p in
compose_form.source_artifact_paths.split("\n") if p.strip()],
+ binary_artifact_paths=[p.strip() for p in
compose_form.binary_artifact_paths.split("\n") if p.strip()],
+ github_repository_name=compose_form.github_repository_name.strip() or
"",
+ github_compose_workflow_path=[
+ p.strip() for p in
compose_form.github_compose_workflow_path.split("\n") if p.strip()
+ ],
+ strict_checking=compose_form.strict_checking,
+ github_vote_workflow_path=project.policy_github_vote_workflow_path,
+ mailto_addresses=project.policy_mailto_addresses,
+ manual_vote=project.policy_manual_vote,
+ min_hours=project.policy_min_hours,
+ pause_for_rm=project.policy_pause_for_rm,
+ release_checklist=project.policy_release_checklist or "",
+ start_vote_template=project.policy_start_vote_template or "",
+ github_finish_workflow_path=project.policy_github_finish_workflow_path,
+ announce_release_template=project.policy_announce_release_template or
"",
+ preserve_download_files=project.policy_preserve_download_files,
+ )
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ try:
+ await wacm.policy.edit(project_name, policy_data)
+ except storage.AccessError as e:
+ return await session.redirect(
+ get.projects.view, name=project_name, error=f"Error editing
compose policy: {e}"
+ )
+
+ return await session.redirect(get.projects.view, name=project_name,
success="Compose options saved successfully.")
+
+
+async def _process_delete_project(
+ session: web.Committer, delete_form: shared.projects.DeleteProjectForm
+) -> web.WerkzeugResponse:
+ project_name = delete_form.project_name
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ try:
+ await wacm.project.delete(project_name)
+ except storage.AccessError as e:
+ return await session.redirect(get.projects.projects, error=f"Error
deleting project: {e}")
+
+ return await session.redirect(get.projects.projects, success=f"Project
'{project_name}' deleted successfully.")
+
+
+async def _process_finish_form(
+ session: web.Committer, finish_form: shared.projects.FinishPolicyForm
+) -> web.WerkzeugResponse:
+ project_name = finish_form.project_name
+
+ async with db.session() as data:
+ project = await data.project(name=project_name, _committee=True,
_release_policy=True).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+
+ policy_data = policy.ReleasePolicyData(
+ project_name=project_name,
+ source_artifact_paths=project.policy_source_artifact_paths,
+ binary_artifact_paths=project.policy_binary_artifact_paths,
+ github_repository_name=project.policy_github_repository_name or "",
+
github_compose_workflow_path=project.policy_github_compose_workflow_path,
+ strict_checking=project.policy_strict_checking,
+ github_vote_workflow_path=project.policy_github_vote_workflow_path,
+ mailto_addresses=project.policy_mailto_addresses,
+ manual_vote=project.policy_manual_vote,
+ min_hours=project.policy_min_hours,
+ pause_for_rm=project.policy_pause_for_rm,
+ release_checklist=project.policy_release_checklist or "",
+ start_vote_template=project.policy_start_vote_template or "",
+ github_finish_workflow_path=[
+ p.strip() for p in
finish_form.github_finish_workflow_path.split("\n") if p.strip()
+ ],
+ announce_release_template=finish_form.announce_release_template or "",
+ preserve_download_files=finish_form.preserve_download_files,
+ )
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ try:
+ await wacm.policy.edit(project_name, policy_data)
+ except storage.AccessError as e:
+ return await session.redirect(
+ get.projects.view, name=project_name, error=f"Error editing
finish policy: {e}"
+ )
+
+ return await session.redirect(get.projects.view, name=project_name,
success="Finish options saved successfully.")
+
+
+async def _process_remove_category(
+ session: web.Committer, remove_form: shared.projects.RemoveCategoryForm
+) -> web.WerkzeugResponse:
+ project_name = remove_form.project_name
+ category_to_remove = remove_form.category_to_remove
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ async with db.session() as data:
+ project = await data.project(name=project_name).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+ modified = await _metadata_category_remove(wacm, project,
category_to_remove)
+
+ if modified:
+ return await session.redirect(
+ get.projects.view, name=project_name, success=f"Category
'{category_to_remove}' removed."
+ )
+ return await session.redirect(
+ get.projects.view, name=project_name, error=f"Category
'{category_to_remove}' does not exist."
+ )
+
+
+async def _process_remove_language(
+ session: web.Committer, remove_form: shared.projects.RemoveLanguageForm
+) -> web.WerkzeugResponse:
+ project_name = remove_form.project_name
+ language_to_remove = remove_form.language_to_remove
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ async with db.session() as data:
+ project = await data.project(name=project_name).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+ modified = await _metadata_language_remove(wacm, project,
language_to_remove)
+
+ if modified:
+ return await session.redirect(
+ get.projects.view, name=project_name, success=f"Language
'{language_to_remove}' removed."
+ )
+ return await session.redirect(
+ get.projects.view, name=project_name, error=f"Language
'{language_to_remove}' does not exist."
+ )
+
+
+async def _process_vote_form(session: web.Committer, vote_form:
shared.projects.VotePolicyForm) -> web.WerkzeugResponse:
+ project_name = vote_form.project_name
+
+ async with db.session() as data:
+ project = await data.project(name=project_name, _committee=True,
_release_policy=True).demand(
+ base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
+ )
+
+ policy_data = policy.ReleasePolicyData(
+ project_name=project_name,
+ source_artifact_paths=project.policy_source_artifact_paths,
+ binary_artifact_paths=project.policy_binary_artifact_paths,
+ github_repository_name=project.policy_github_repository_name or "",
+
github_compose_workflow_path=project.policy_github_compose_workflow_path,
+ strict_checking=project.policy_strict_checking,
+ github_vote_workflow_path=[p.strip() for p in
vote_form.github_vote_workflow_path.split("\n") if p.strip()],
+ mailto_addresses=[vote_form.mailto_addresses],
+ manual_vote=vote_form.manual_vote,
+ min_hours=vote_form.min_hours,
+ pause_for_rm=vote_form.pause_for_rm,
+ release_checklist=vote_form.release_checklist or "",
+ start_vote_template=vote_form.start_vote_template or "",
+ github_finish_workflow_path=project.policy_github_finish_workflow_path,
+ announce_release_template=project.policy_announce_release_template or
"",
+ preserve_download_files=project.policy_preserve_download_files,
+ )
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ try:
+ await wacm.policy.edit(project_name, policy_data)
+ except storage.AccessError as e:
+ return await session.redirect(get.projects.view,
name=project_name, error=f"Error editing vote policy: {e}")
+
+ return await session.redirect(get.projects.view, name=project_name,
success="Vote options saved successfully.")
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 6e5c117..014c62a 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -17,29 +17,31 @@
from __future__ import annotations
-import datetime
-import http.client
import re
-from typing import Any
+from typing import Annotated, Literal
import asfquart.base as base
+import pydantic
import quart
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.log as log
-import atr.models.policy as policy
-import atr.models.sql as sql
-import atr.registry as registry
-import atr.shared as shared
import atr.storage as storage
import atr.template as template
-import atr.user as user
import atr.util as util
import atr.web as web
+type COMPOSE = Literal["compose"]
+type VOTE = Literal["vote"]
+type FINISH = Literal["finish"]
+type ADD_CATEGORY = Literal["add_category"]
+type REMOVE_CATEGORY = Literal["remove_category"]
+type ADD_LANGUAGE = Literal["add_language"]
+type REMOVE_LANGUAGE = Literal["remove_language"]
+type DELETE_PROJECT = Literal["delete_project"]
+
class AddForm(forms.Typed):
committee_name = forms.hidden()
@@ -48,182 +50,179 @@ class AddForm(forms.Typed):
submit = forms.submit("Add project")
-class ProjectMetadataForm(forms.Typed):
- project_name = forms.hidden()
- category_to_add = forms.optional("New category name")
- language_to_add = forms.optional("New language name")
-
-
-class ReleasePolicyForm(forms.Typed):
- """
- A Form to create or edit a ReleasePolicy.
-
- TODO: Currently only a single mailto_address is supported.
- see:
https://stackoverflow.com/questions/49066046/append-entry-to-fieldlist-with-flask-wtforms-using-ajax
- """
-
- project_name = forms.hidden()
-
- # Compose section
- source_artifact_paths = forms.textarea(
+class ComposePolicyForm(form.Form):
+ variant: COMPOSE = form.value(COMPOSE)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+ source_artifact_paths: str = form.label(
"Source artifact paths",
- optional=True,
- rows=5,
- description="Paths to source artifacts to be included in the release.",
+ "Paths to source artifacts to be included in the release.",
+ widget=form.Widget.TEXTAREA,
)
- binary_artifact_paths = forms.textarea(
+ binary_artifact_paths: str = form.label(
"Binary artifact paths",
- optional=True,
- rows=5,
- description="Paths to binary artifacts to be included in the release.",
+ "Paths to binary artifacts to be included in the release.",
+ widget=form.Widget.TEXTAREA,
)
- github_repository_name = forms.optional(
+ github_repository_name: str = form.label(
"GitHub repository name",
- description="The name of the GitHub repository to use for the release,
excluding the apache/ prefix.",
+ "The name of the GitHub repository to use for the release, excluding
the apache/ prefix.",
)
- github_compose_workflow_path = forms.textarea(
+ github_compose_workflow_path: str = form.label(
"GitHub compose workflow paths",
- optional=True,
- rows=5,
- description="The full paths to the GitHub workflows to use for the
release,"
- " including the .github/workflows/ prefix.",
+ "The full paths to the GitHub workflows to use for the release,
including the .github/workflows/ prefix.",
+ widget=form.Widget.TEXTAREA,
)
- strict_checking = forms.boolean(
- "Strict checking", description="If enabled, then the release cannot be
voted upon unless all checks pass."
+ strict_checking: form.Bool = form.label(
+ "Strict checking",
+ "If enabled, then the release cannot be voted upon unless all checks
pass.",
)
- # Vote section
- github_vote_workflow_path = forms.textarea(
+ @pydantic.model_validator(mode="after")
+ def validate_github_fields(self) -> ComposePolicyForm:
+ github_repository_name = self.github_repository_name.strip()
+ compose_raw = self.github_compose_workflow_path or ""
+ compose = [p.strip() for p in compose_raw.split("\n") if p.strip()]
+
+ if compose and (not github_repository_name):
+ raise ValueError("GitHub repository name is required when any
workflow path is set.")
+
+ if github_repository_name and ("/" in github_repository_name):
+ raise ValueError("GitHub repository name must not contain a
slash.")
+
+ if compose:
+ for p in compose:
+ if not p.startswith(".github/workflows/"):
+ raise ValueError("GitHub workflow paths must start with
'.github/workflows/'.")
+
+ return self
+
+
+class VotePolicyForm(form.Form):
+ variant: VOTE = form.value(VOTE)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+ github_vote_workflow_path: str = form.label(
"GitHub vote workflow paths",
- optional=True,
- rows=5,
- description="The full paths to the GitHub workflows to use for the
release,"
- " including the .github/workflows/ prefix.",
+ "The full paths to the GitHub workflows to use for the release,
including the .github/workflows/ prefix.",
+ widget=form.Widget.TEXTAREA,
)
- mailto_addresses = forms.string(
+ mailto_addresses: form.Email = form.label(
"Email",
- validators=[forms.REQUIRED, forms.EMAIL],
- placeholder="E.g. [email protected]",
- description=f"The mailing list where vote emails are sent. This is
usually"
- "your dev list. ATR will currently only send test announcement emails
to"
- f"{util.USER_TESTS_ADDRESS}.",
+ f"The mailing list where vote emails are sent. This is usually your
dev list. "
+ f"ATR will currently only send test announcement emails to
{util.USER_TESTS_ADDRESS}.",
)
- manual_vote = forms.boolean(
+ manual_vote: form.Bool = form.label(
"Manual voting process",
- description="If this is set then the vote will be completely manual
and following policy is ignored.",
+ "If this is set then the vote will be completely manual and following
policy is ignored.",
)
- default_min_hours_value_at_render = forms.hidden()
- min_hours = forms.integer(
+ min_hours: form.Int = form.label(
"Minimum voting period",
- validators=[util.validate_vote_duration],
+ "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.",
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 = forms.boolean(
- "Pause for RM", description="If enabled, RM can confirm manually if
the vote has passed."
+ pause_for_rm: form.Bool = form.label(
+ "Pause for RM",
+ "If enabled, RM can confirm manually if the vote has passed.",
)
- release_checklist = forms.textarea(
+ release_checklist: str = form.label(
"Release checklist",
- optional=True,
- rows=10,
- description="Markdown text describing how to test release candidates.",
+ "Markdown text describing how to test release candidates.",
+ widget=form.Widget.TEXTAREA,
)
- default_start_vote_template_hash = forms.hidden()
- start_vote_template = forms.textarea(
+ start_vote_template: str = form.label(
"Start vote template",
- optional=True,
- rows=10,
- description="Email template for messages to start a vote on a
release.",
+ "Email template for messages to start a vote on a release.",
+ widget=form.Widget.TEXTAREA,
)
- # Finish section
- default_announce_release_template_hash = forms.hidden()
- announce_release_template = forms.textarea(
- "Announce release template",
- optional=True,
- rows=10,
- description="Email template for messages to announce a finished
release.",
- )
- github_finish_workflow_path = forms.textarea(
+ @pydantic.model_validator(mode="after")
+ def validate_vote_fields(self) -> VotePolicyForm:
+ vote_raw = self.github_vote_workflow_path or ""
+ vote = [p.strip() for p in vote_raw.split("\n") if p.strip()]
+
+ if vote:
+ for p in vote:
+ if not p.startswith(".github/workflows/"):
+ raise ValueError("GitHub workflow paths must start with
'.github/workflows/'.")
+
+ min_hours = self.min_hours
+ if min_hours != 0 and (min_hours < 72 or min_hours > 144):
+ raise ValueError("Minimum voting period must be 0 or between 72
and 144 hours inclusive.")
+
+ return self
+
+
+class FinishPolicyForm(form.Form):
+ variant: FINISH = form.value(FINISH)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+ github_finish_workflow_path: str = form.label(
"GitHub finish workflow paths",
- optional=True,
- rows=5,
- description="The full paths to the GitHub workflows to use for the
release,"
- " including the .github/workflows/ prefix.",
+ "The full paths to the GitHub workflows to use for the release,
including the .github/workflows/ prefix.",
+ widget=form.Widget.TEXTAREA,
)
- preserve_download_files = forms.boolean(
+ announce_release_template: str = form.label(
+ "Announce release template",
+ "Email template for messages to announce a finished release.",
+ widget=form.Widget.TEXTAREA,
+ )
+ preserve_download_files: form.Bool = form.label(
"Preserve download files",
- description="If enabled, existing download files will not be
overwritten.",
+ "If enabled, existing download files will not be overwritten.",
)
- submit_policy = forms.submit("Save")
-
- async def validate(self, extra_validators: dict[str, Any] | None = None)
-> bool: # noqa: C901
- await super().validate(extra_validators=extra_validators)
-
- if self.manual_vote.data:
- for field_name in (
- "mailto_addresses",
- "min_hours",
- "pause_for_rm",
- "release_checklist",
- "start_vote_template",
- ):
- field = getattr(self, field_name, None)
- if field is not None:
- forms.clear_errors(field)
- self.errors.pop(field_name, None)
-
- if self.manual_vote.data and self.strict_checking.data:
- msg = "Manual voting process and strict checking cannot be enabled
simultaneously."
- forms.error(self.manual_vote, msg)
- forms.error(self.strict_checking, msg)
-
- github_repository_name = (self.github_repository_name.data or
"").strip()
- compose_raw = self.github_compose_workflow_path.data or ""
- vote_raw = self.github_vote_workflow_path.data or ""
- finish_raw = self.github_finish_workflow_path.data or ""
- compose = [p.strip() for p in compose_raw.split("\n") if p.strip()]
- vote = [p.strip() for p in vote_raw.split("\n") if p.strip()]
+ @pydantic.model_validator(mode="after")
+ def validate_finish_fields(self) -> FinishPolicyForm:
+ finish_raw = self.github_finish_workflow_path or ""
finish = [p.strip() for p in finish_raw.split("\n") if p.strip()]
- any_path = bool(compose or vote or finish)
- if any_path and (not github_repository_name):
- forms.error(
- self.github_repository_name,
- "GitHub repository name is required when any workflow path is
set.",
- )
-
- if github_repository_name and ("/" in github_repository_name):
- forms.error(self.github_repository_name, "GitHub repository name
must not contain a slash.")
-
- if compose:
- for p in compose:
- if not p.startswith(".github/workflows/"):
- forms.error(
- self.github_compose_workflow_path,
- "GitHub workflow paths must start with
'.github/workflows/'.",
- )
- break
- if vote:
- for p in vote:
- if not p.startswith(".github/workflows/"):
- forms.error(
- self.github_vote_workflow_path,
- "GitHub workflow paths must start with
'.github/workflows/'.",
- )
- break
if finish:
for p in finish:
if not p.startswith(".github/workflows/"):
- forms.error(
- self.github_finish_workflow_path,
- "GitHub workflow paths must start with
'.github/workflows/'.",
- )
- break
+ raise ValueError("GitHub workflow paths must start with
'.github/workflows/'.")
+
+ return self
+
+
+class AddCategoryForm(form.Form):
+ variant: ADD_CATEGORY = form.value(ADD_CATEGORY)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+ category_to_add: str = form.label("New category name")
+
+
+class RemoveCategoryForm(form.Form):
+ variant: REMOVE_CATEGORY = form.value(REMOVE_CATEGORY)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+ category_to_remove: str = form.label("Category to remove",
widget=form.Widget.HIDDEN)
+
+
+class AddLanguageForm(form.Form):
+ variant: ADD_LANGUAGE = form.value(ADD_LANGUAGE)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+ language_to_add: str = form.label("New language name")
- return not self.errors
+
+class RemoveLanguageForm(form.Form):
+ variant: REMOVE_LANGUAGE = form.value(REMOVE_LANGUAGE)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+ language_to_remove: str = form.label("Language to remove",
widget=form.Widget.HIDDEN)
+
+
+class DeleteProjectForm(form.Form):
+ variant: DELETE_PROJECT = form.value(DELETE_PROJECT)
+ project_name: str = form.label("Project name", widget=form.Widget.HIDDEN)
+
+
+type ProjectViewForm = Annotated[
+ ComposePolicyForm
+ | VotePolicyForm
+ | FinishPolicyForm
+ | AddCategoryForm
+ | RemoveCategoryForm
+ | AddLanguageForm
+ | RemoveLanguageForm
+ | DeleteProjectForm,
+ form.DISCRIMINATOR,
+]
async def add_project(session: web.Committer, committee_name: str) ->
web.WerkzeugResponse | str:
@@ -250,203 +249,6 @@ You must start with your committee label, and you must
use lower case.
return await template.render("project-add-project.html", form=form,
committee_name=committee.display_name)
-async def view(session: web.Committer, name: str) -> web.WerkzeugResponse |
str:
- policy_form = None
- metadata_form = None
- can_edit = False
-
- async with db.session() as data:
- project = await data.project(
- name=name, _committee=True, _committee_public_signing_keys=True,
_release_policy=True
- ).demand(http.client.HTTPException(404))
-
- 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(session,
project, form_data)
- if edited_metadata is True:
- return quart.redirect(util.as_url(get.projects.view,
name=project.name))
- elif "submit_policy" in form_data:
- policy_form = await ReleasePolicyForm.create_form(data=form_data)
- if await policy_form.validate_on_submit():
- policy_data =
policy.ReleasePolicyData.model_validate(policy_form.data)
- async with storage.write(session) as write:
- wacm = await
write.as_project_committee_member(project.name)
- try:
- await wacm.policy.edit(project.name, policy_data)
- except storage.AccessError as e:
- return await session.redirect(
- get.projects.view, name=project.name,
error=f"Error editing policy: {e}"
- )
- return quart.redirect(util.as_url(get.projects.view,
name=project.name))
- else:
- log.info(f"policy_form.errors: {policy_form.errors}")
- else:
- log.info(f"Unknown form data: {form_data}")
-
- 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)
- candidate_drafts = await interaction.candidate_drafts(project)
- candidates = await interaction.candidates(project)
- previews = await interaction.previews(project)
- full_releases = await interaction.full_releases(project)
-
- return await template.render(
- "project-view.html",
- project=project,
- algorithms=shared.algorithms,
- candidate_drafts=candidate_drafts,
- candidates=candidates,
- previews=previews,
- full_releases=full_releases,
- number_of_release_files=util.number_of_release_files,
- now=datetime.datetime.now(datetime.UTC),
- empty_form=await forms.Empty.create_form(),
- policy_form=policy_form,
- can_edit=can_edit,
- metadata_form=metadata_form,
- forbidden_categories=registry.FORBIDDEN_PROJECT_CATEGORIES,
- )
-
-
-async def _metadata_category_add(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project,
category_to_add: str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.category_add(project,
category_to_add.strip())
- except storage.AccessError as e:
- await quart.flash(f"Error adding category: {e}", "error")
- if modified:
- await quart.flash(f"Category '{category_to_add}' added.", "success")
- else:
- await quart.flash(f"Category '{category_to_add}' already exists.",
"error")
- return modified
-
-
-async def _metadata_category_remove(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.category_remove(project, action_value)
- except storage.AccessError as e:
- await quart.flash(f"Error removing category: {e}", "error")
- if modified:
- await quart.flash(f"Category '{action_value}' removed.", "success")
- else:
- await quart.flash(f"Category '{action_value}' does not exist.",
"error")
- return modified
-
-
-async def _metadata_edit(
- session: web.Committer, project: sql.Project, form_data: dict[str, str]
-) -> tuple[bool, ProjectMetadataForm]:
- metadata_form = await ProjectMetadataForm.create_form(data=form_data)
-
- validated = await metadata_form.validate_on_submit()
- if not validated:
- return False, metadata_form
-
- 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:
- action_type = action_full
-
- # TODO: Add error handling
- modified = False
- category_to_add = metadata_form.category_to_add.data
- language_to_add = metadata_form.language_to_add.data
-
- async with storage.write(session) as write:
- wacm = await write.as_project_committee_member(project.name)
-
- if (action_type == "add_category") and category_to_add:
- modified = await _metadata_category_add(wacm, project,
category_to_add)
- elif (action_type == "remove_category") and action_value:
- modified = await _metadata_category_remove(wacm, project,
action_value)
- elif (action_type == "add_language") and language_to_add:
- modified = await _metadata_language_add(wacm, project,
language_to_add)
- elif (action_type == "remove_language") and action_value:
- modified = await _metadata_language_remove(wacm, project,
action_value)
-
- return modified, metadata_form
-
-
-async def _metadata_language_add(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project,
language_to_add: str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.language_add(project, language_to_add)
- except storage.AccessError as e:
- await quart.flash(f"Error adding language: {e}", "error")
- if modified:
- await quart.flash(f"Language '{language_to_add}' added.", "success")
- else:
- await quart.flash(f"Language '{language_to_add}' already exists.",
"error")
- return modified
-
-
-async def _metadata_language_remove(
- wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
-) -> bool:
- modified = False
- try:
- modified = await wacm.project.language_remove(project, action_value)
- except storage.AccessError as e:
- await quart.flash(f"Error removing language: {e}", "error")
- if modified:
- await quart.flash(f"Language '{action_value}' removed.", "success")
- else:
- await quart.flash(f"Language '{action_value}' does not exist.",
"error")
- return modified
-
-
-async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
- # TODO: Use form order for all of these fields
- policy_form = await ReleasePolicyForm.create_form()
- policy_form.project_name.data = project.name
- if project.policy_mailto_addresses:
- policy_form.mailto_addresses.data = project.policy_mailto_addresses[0]
- else:
- 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
- policy_form.start_vote_template.data = project.policy_start_vote_template
- policy_form.announce_release_template.data =
project.policy_announce_release_template
- policy_form.binary_artifact_paths.data =
"\n".join(project.policy_binary_artifact_paths)
- policy_form.source_artifact_paths.data =
"\n".join(project.policy_source_artifact_paths)
- policy_form.pause_for_rm.data = project.policy_pause_for_rm
- policy_form.strict_checking.data = project.policy_strict_checking
- policy_form.github_repository_name.data =
project.policy_github_repository_name
- policy_form.github_compose_workflow_path.data =
"\n".join(project.policy_github_compose_workflow_path)
- policy_form.github_vote_workflow_path.data =
"\n".join(project.policy_github_vote_workflow_path)
- policy_form.github_finish_workflow_path.data =
"\n".join(project.policy_github_finish_workflow_path)
- policy_form.preserve_download_files.data =
project.policy_preserve_download_files
-
- # 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: AddForm, session: web.Committer) ->
web.WerkzeugResponse:
form_values = await _project_add_validate(form)
if form_values is None:
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
deleted file mode 100644
index a958388..0000000
--- a/atr/templates/project-view.html
+++ /dev/null
@@ -1,538 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
- Project ~ ATR
-{% endblock title %}
-
-{% block description %}
- 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">
- <h1>{{ project.display_name }}</h1>
- </div>
- {% if project.status.value.lower() != "active" %}
- <div class="col-sm-auto">
- <span class="badge text-bg-secondary">{{ project.status.value.lower()
}}</span>
- </div>
- {% endif %}
- </div>
-
- {{ forms.errors_summary(policy_form) }}
-
- <p class="mb-4">
- <a href="{{ as_url(get.start.selected, project_name=project.name) }}"
- class="btn btn-sm btn-outline-primary">Start a new release</a>
- </p>
-
- <div class="card mb-4">
- <div class="card-header bg-light">
- <h3 class="mb-2">Project label</h3>
- </div>
- <div class="card-body">
- <code class="fs-6">{{ project.name }}</code>
- </div>
- </div>
-
- {% set is_admin = is_admin_fn(current_user.uid) %}
- {% set is_committee_member = is_committee_member_fn(project.committee,
current_user.uid) %}
-
- <div class="card mb-4">
- <div class="card-header bg-light">
- <h3 class="mb-2">PMC</h3>
- </div>
- <div class="card-body">
- <div class="d-flex flex-wrap gap-3 small mb-1">
- <a href="{{ as_url(get.committees.view, name=project.committee.name)
}}">{{ project.committee.display_name }}</a>
- </div>
- </div>
- </div>
-
- <div class="card mb-4">
- <div class="card-header bg-light">
- <h3 class="mb-2">Description</h3>
- </div>
- <div class="card-body">
- <div class="d-flex flex-wrap gap-3 small mb-1">
- <span class="fs-6">{{ project.description }}</span>
- </div>
- </div>
- </div>
-
- {% if project.status.value.lower() == "active" %}
- <div class="card mb-4">
- <div class="card-header bg-light d-flex justify-content-between
align-items-center">
- <h3 class="mb-0">Release policy</h3>
- </div>
- <div class="card-body">
- {% if can_edit and policy_form %}
- <form method="post"
- action="{{ as_url(post.projects.view, name=project.name) }}"
- class="atr-canary py-4 px-5"
- novalidate>
- {{ policy_form.hidden_tag() if policy_form.hidden_tag }}
- {{ policy_form.project_name(value=project.name) }}
- {{ policy_form.default_start_vote_template_hash() }}
- {{ policy_form.default_announce_release_template_hash() }}
- {{ policy_form.default_min_hours_value_at_render() }}
-
- <div class="row mb-3">
- <h3 class="mt-3 col-md-3 col-form-label text-md-end
fs-4">Compose options</h3>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.source_artifact_paths, col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.source_artifact_paths,
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.source_artifact_paths) }}
- {{ forms.description(policy_form.source_artifact_paths) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.binary_artifact_paths, col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.binary_artifact_paths,
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.binary_artifact_paths) }}
- {{ forms.description(policy_form.binary_artifact_paths) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.github_repository_name, col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.github_repository_name,
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.github_repository_name) }}
- {{ forms.description(policy_form.github_repository_name) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.github_compose_workflow_path,
col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.github_compose_workflow_path,
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.github_compose_workflow_path) }}
- {{ forms.description(policy_form.github_compose_workflow_path)
}}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row">
- {{ forms.label(policy_form.strict_checking, col="md3-high") }}
- <div class="col-sm-8">
- <div class="form-check">
- {{ forms.widget(policy_form.strict_checking,
classes="form-check-input", boolean_label="Enable") }}
- {{ forms.errors(policy_form.strict_checking,
classes="invalid-feedback d-block") }}
- </div>
- {{ forms.description(policy_form.strict_checking) }}
- </div>
- </div>
-
- <div class="row mt-5 mb-3">
- <h3 class="col-md-3 col-form-label text-md-end fs-4">Vote
options</h3>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.github_vote_workflow_path, col="md3")
}}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.github_vote_workflow_path,
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.github_vote_workflow_path) }}
- {{ forms.description(policy_form.github_vote_workflow_path) }}
- </div>
- </div>
-
- {% if not project.committee.is_podling %}
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.manual_vote, col="md3-high") }}
- <div class="col-sm-8">
- <div class="form-check">
- {{ forms.widget(policy_form.manual_vote,
classes="form-check-input", boolean_label="Enable") }}
- {{ forms.errors(policy_form.manual_vote,
classes="invalid-feedback d-block") }}
- </div>
- {{ forms.description(policy_form.manual_vote) }}
- </div>
- </div>
- {% endif %}
-
- <div id="vote-options-extra">
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.mailto_addresses, col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.mailto_addresses) }}
- {{ forms.errors(policy_form.mailto_addresses) }}
- {{ forms.description(policy_form.mailto_addresses) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.min_hours, col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.min_hours) }}
- {{ forms.errors(policy_form.min_hours) }}
- {{ forms.description(policy_form.min_hours) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.pause_for_rm, col="md3-high") }}
- <div class="col-sm-8">
- <div class="form-check">
- {{ forms.widget(policy_form.pause_for_rm,
classes="form-check-input", boolean_label="Enable") }}
- {{ forms.errors(policy_form.pause_for_rm,
classes="invalid-feedback d-block") }}
- </div>
- {{ forms.description(policy_form.pause_for_rm) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.release_checklist, col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.release_checklist, rows="10",
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.release_checklist) }}
- {{ forms.description(policy_form.release_checklist) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row">
- {{ forms.label(policy_form.start_vote_template, col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.start_vote_template, rows="10",
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.start_vote_template) }}
- {{ forms.description(policy_form.start_vote_template) }}
- </div>
- </div>
-
- </div>
-
- <div class="row mt-5 mb-3">
- <h3 class="col-md-3 col-form-label text-md-end fs-4">Finish
options</h3>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.github_finish_workflow_path,
col="md3") }}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.github_finish_workflow_path,
classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.github_finish_workflow_path) }}
- {{ forms.description(policy_form.github_finish_workflow_path)
}}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.announce_release_template, col="md3")
}}
- <div class="col-sm-8">
- {{ forms.widget(policy_form.announce_release_template,
rows="10", classes="form-control font-monospace") }}
- {{ forms.errors(policy_form.announce_release_template) }}
- {{ forms.description(policy_form.announce_release_template) }}
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(policy_form.preserve_download_files,
col="md3-high") }}
- <div class="col-sm-8">
- <div class="form-check">
- {{ forms.widget(policy_form.preserve_download_files,
classes="form-check-input", boolean_label="Enable") }}
- {{ forms.errors(policy_form.preserve_download_files,
classes="invalid-feedback d-block") }}
- </div>
- {{ forms.description(policy_form.preserve_download_files) }}
- </div>
- </div>
-
- <div class="row">
- <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 %}
- <div class="card h-100 border">
- <div class="card-body">
- <table class="table mb-0">
- <tbody>
- <tr>
- <th class="border-0 w-25">Email</th>
- <td class="text-break border-0">
- {% if project.policy_mailto_addresses %}
- <a href="mailto:{{ project.policy_mailto_addresses[0]
}}">{{ project.policy_mailto_addresses[0] }}</a>
- {% else %}
- Not set
- {% endif %}
- </td>
- </tr>
- <tr>
- <th class="border-0">Manual vote process</th>
- <td class="text-break border-0">{{
project.policy_manual_vote }}</td>
- </tr>
- <tr>
- <th class="border-0">Minimum voting period</th>
- <td class="text-break border-0">{{
project.policy_min_hours }}h</td>
- </tr>
- <tr>
- <th class="border-0">Release checklist</th>
- <td class="text-break border-0">
- {% if project.policy_release_checklist|length > 0 %}
- <textarea readonly class="form-control font-monospace"
rows="10">{{ project.policy_release_checklist }}</textarea>
- {% else %}
- None
- {% endif %}
- </td>
- </tr>
- <tr>
- <th class="border-0">Start vote template</th>
- <td class="text-break border-0">
- {% if project.policy_start_vote_template|length > 0 %}
- <textarea readonly class="form-control font-monospace"
rows="10">{{ project.policy_start_vote_template }}</textarea>
- {% else %}
- None (System default will be used)
- {% endif %}
- </td>
- </tr>
- <tr>
- <th class="border-0">Announce release template</th>
- <td class="text-break border-0">
- {% if project.policy_announce_release_template|length >
0 %}
- <textarea readonly class="form-control font-monospace"
rows="10">{{ project.policy_announce_release_template }}</textarea>
- {% else %}
- None (System default will be used)
- {% endif %}
- </td>
- </tr>
- <tr>
- <th class="border-0">Pause for RM</th>
- <td class="text-break border-0">{{
project.policy_pause_for_rm }}</td>
- </tr>
- </tbody>
- </table>
- </div>
- </div>
- {% else %}
- <div>None defined.</div>
- {% endif %}
- </div>
- </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">
- {{ forms.errors_summary(metadata_form) }}
- <form method="post"
- action="{{ as_url(post.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>
- {% if cat not in forbidden_categories %}
- <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>
- {% endif %}
- </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">
- {{ forms.errors_summary(metadata_form) }}
- <form method="post"
- action="{{ as_url(post.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 %}
-
- {% if (is_committee_member or is_admin) %}
- {% if candidate_drafts|length > 0 %}
- <h2>Draft candidate releases</h2>
- <div class="d-flex flex-wrap gap-2 mb-4">
- {% for draft in candidate_drafts %}
- <a href="{{ as_url(get.draft.view, project_name=project.name,
version_name=draft.version) }}"
- class="btn btn-sm btn-outline-secondary py-2 px-3"
- title="View draft {{ project.name }} {{ draft.version }}">
- {{ project.name }} {{ draft.version }}
- {% set file_count = number_of_release_files(draft) %}
- <span class="badge bg-secondary ms-2">{{ file_count }}
- {% if file_count == 1 %}
- file
- {% else %}
- files
- {% endif %}
- </span>
- </a>
- {% endfor %}
- </div>
- {% endif %}
- {% if candidates|length > 0 %}
- <h2>Candidate releases</h2>
- <div class="d-flex flex-wrap gap-2 mb-4">
- {% for candidate in candidates %}
- <a href="{{ as_url(get.candidate.view, project_name=project.name,
version_name=candidate.version) }}"
- class="btn btn-sm btn-outline-info py-2 px-3"
- title="View candidate {{ project.name }} {{ candidate.version }}">
- {{ project.name }} {{ candidate.version }}
- {% set file_count = number_of_release_files(candidate) %}
- <span class="badge bg-info ms-2">{{ file_count }}
- {% if file_count == 1 %}
- file
- {% else %}
- files
- {% endif %}
- </span>
- </a>
- {% endfor %}
- </div>
- {% endif %}
- {% if previews|length > 0 %}
- <h2>Preview releases</h2>
- <div class="d-flex flex-wrap gap-2 mb-4">
- {% for preview in previews %}
- <a href="{{ as_url(get.preview.view, project_name=project.name,
version_name=preview.version) }}"
- class="btn btn-sm btn-outline-warning py-2 px-3"
- title="View preview {{ project.name }} {{ preview.version }}">
- {{ project.name }} {{ preview.version }}
- {% set file_count = number_of_release_files(preview) %}
- <span class="badge bg-warning ms-2">{{ file_count }}
- {% if file_count == 1 %}
- file
- {% else %}
- files
- {% endif %}
- </span> </a>
- {% endfor %}
- </div>
- {% endif %}
- {% if full_releases|length > 0 %}
- <h2>Full releases</h2>
- <div class="d-flex flex-wrap gap-2 mb-4">
- {% for release in full_releases %}
- <a href="{{ as_url(get.release.view, project_name=project.name,
version_name=release.version) }}"
- class="btn btn-sm btn-outline-success py-2 px-3"
- title="View release {{ project.name }} {{ release.version }}">
- {{ project.name }} {{ release.version }}
- {% set file_count = number_of_release_files(release) %}
- <span class="badge bg-success ms-2">{{ file_count }}
- {% if file_count == 1 %}
- file
- {% else %}
- files
- {% endif %}
- </span>
- </a>
- {% endfor %}
- </div>
- {% endif %}
- {% endif %}
-
- {% if project.created_by == current_user.uid %}
- <h2>Actions</h2>
- <div class="my-3">
- <form method="post"
- action="{{ as_url(post.projects.delete) }}"
- class="d-inline-block m-0"
- onsubmit="return confirm('Are you sure you want to delete the
project \'{{ project.display_name }}\'? This cannot be undone.');">
- {{ empty_form.hidden_tag() }}
-
- <input type="hidden" name="project_name" value="{{ project.name }}"
/>
- <button type="submit"
- class="btn btn-sm btn-outline-danger"
- title="Delete {{ project.display_name }}">
- <i class="bi bi-trash"></i> Delete project
- </button>
- </form>
- </div>
- {% endif %}
- {% if (is_committee_member or is_admin) %}
- <p>
- <a href="{{ as_url(get.projects.add_project,
committee_name=project.committee.name) }}"
- class="btn btn-sm btn-outline-primary">Create a sibling project</a>
- </p>
- {% endif %}
- {% endblock content %}
-
- {% block javascripts %}
- {{ super() }}
- {% if not project.committee.is_podling %}
- <script>
- document.addEventListener("DOMContentLoaded", () => {
- const manual =
document.querySelector("input[name='manual_vote']");
- const extra = document.getElementById("vote-options-extra");
- if (!manual || !extra) return;
-
- function toggle() {
- extra.hidden = manual.checked;
- extra.querySelectorAll("input, textarea, select").forEach(el
=> (el.disabled = manual.checked));
- }
-
- manual.addEventListener("change", toggle);
- toggle();
- });
- </script>
- {% endif %}
- {% endblock javascripts %}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]