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]

Reply via email to