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 bc612f6  Make the forms to finish a release more type safe
bc612f6 is described below

commit bc612f69d57d8c5f1d186a3490ad90cd250c359d
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Nov 13 11:00:52 2025 +0000

    Make the forms to finish a release more type safe
---
 atr/get/finish.py                         | 465 +++++++++++++++++++++++++++++-
 atr/htm.py                                |   1 +
 atr/post/finish.py                        | 141 ++++++++-
 atr/shared/finish.py                      | 419 ++-------------------------
 atr/static/js/finish-selected-move.js     |   1 +
 atr/static/js/finish-selected-move.js.map |   2 +-
 atr/static/ts/finish-selected-move.ts     |   1 +
 atr/templates/finish-selected.html        | 231 ---------------
 8 files changed, 631 insertions(+), 630 deletions(-)

diff --git a/atr/get/finish.py b/atr/get/finish.py
index d0f015b..3033b5e 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -16,14 +16,477 @@
 # under the License.
 
 
+import dataclasses
+import json
+import pathlib
+from typing import Any
+
+import aiofiles.os
+import asfquart.base as base
+import htpy
+import markupsafe
+import quart
+import quart_wtf.utils as utils
+
+import atr.analysis as analysis
 import atr.blueprints.get as get
+import atr.db as db
+import atr.form as form
+import atr.get.announce as announce
+import atr.get.distribution as distribution
+import atr.get.download as download
+import atr.get.preview as preview
+import atr.get.revisions as revisions
+import atr.get.root as root
+import atr.htm as htm
+import atr.mapping as mapping
+import atr.models.sql as sql
 import atr.shared as shared
+import atr.template as template
+import atr.util as util
 import atr.web as web
 
 
[email protected]
+class RCTagAnalysisResult:
+    affected_paths_preview: list[tuple[str, str]]
+    affected_count: int
+    total_paths: int
+
+
 @get.committer("/finish/<project_name>/<version_name>")
 async def selected(
     session: web.Committer, project_name: str, version_name: str
 ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str:
     """Finish a release preview."""
-    return await shared.finish.selected(session, project_name, version_name)
+    try:
+        (
+            release,
+            source_files_rel,
+            target_dirs,
+            deletable_dirs,
+            rc_analysis,
+        ) = await _get_page_data(project_name, version_name)
+    except ValueError:
+        async with db.session() as data:
+            release_fallback = await data.release(
+                project_name=project_name,
+                version=version_name,
+                _committee=True,
+            ).get()
+            if release_fallback:
+                return await mapping.release_as_redirect(session, 
release_fallback)
+
+        await quart.flash("Preview revision directory not found.", "error")
+        return await session.redirect(root.index)
+    except FileNotFoundError:
+        await quart.flash("Preview revision directory not found.", "error")
+        return await session.redirect(root.index)
+
+    return await _render_page(
+        release=release,
+        source_files_rel=source_files_rel,
+        target_dirs=target_dirs,
+        deletable_dirs=deletable_dirs,
+        rc_analysis=rc_analysis,
+    )
+
+
+async def _analyse_rc_tags(latest_revision_dir: pathlib.Path) -> 
RCTagAnalysisResult:
+    r = RCTagAnalysisResult(
+        affected_paths_preview=[],
+        affected_count=0,
+        total_paths=0,
+    )
+
+    if not latest_revision_dir.exists():
+        return r
+
+    async for p_rel in util.paths_recursive_all(latest_revision_dir):
+        r.total_paths += 1
+        original_path_str = str(p_rel)
+        stripped_path_str = str(analysis.candidate_removed(p_rel))
+        if original_path_str == stripped_path_str:
+            continue
+        r.affected_count += 1
+        if len(r.affected_paths_preview) >= 5:
+            # Can't break here, because we need to update the counts
+            continue
+        r.affected_paths_preview.append((original_path_str, stripped_path_str))
+
+    return r
+
+
+async def _deletable_choices(latest_revision_dir: pathlib.Path, target_dirs: 
set[pathlib.Path]) -> Any:
+    # This should be -> list[tuple[str, str]], but that causes pyright to 
complain incorrectly
+    # Details in 
pyright/dist/dist/typeshed-fallback/stubs/WTForms/wtforms/fields/choices.pyi
+    # _Choice: TypeAlias = tuple[Any, str] | tuple[Any, str, dict[str, Any]]
+    # Then it wants us to use list[_Choice] (= list[tuple[Any, str]])
+    # But it says, incorrectly, that list[tuple[str, str]] is not a 
list[_Choice]
+    # This mistake is not made by mypy
+    empty_deletable_dirs: list[pathlib.Path] = []
+    if latest_revision_dir.exists():
+        for d_rel in target_dirs:
+            if d_rel == pathlib.Path("."):
+                # Disallow deletion of the root directory
+                continue
+            d_full = latest_revision_dir / d_rel
+            if await aiofiles.os.path.isdir(d_full) and not await 
aiofiles.os.listdir(d_full):
+                empty_deletable_dirs.append(d_rel)
+    return sorted([(str(p), str(p)) for p in empty_deletable_dirs])
+
+
+async def _get_page_data(
+    project_name: str, version_name: str
+) -> tuple[sql.Release, list[pathlib.Path], set[pathlib.Path], list[tuple[str, 
str]], RCTagAnalysisResult]:
+    """Get all the data needed to render the finish page."""
+    async with db.session() as data:
+        release = await data.release(
+            project_name=project_name,
+            version=version_name,
+            _committee=True,
+        ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
+
+    if release.phase != sql.ReleasePhase.RELEASE_PREVIEW:
+        raise ValueError("Release is not in preview phase")
+
+    latest_revision_dir = util.release_directory(release)
+    source_files_rel, target_dirs = await 
_sources_and_targets(latest_revision_dir)
+    deletable_dirs = await _deletable_choices(latest_revision_dir, target_dirs)
+    rc_analysis_result = await _analyse_rc_tags(latest_revision_dir)
+
+    return release, source_files_rel, target_dirs, deletable_dirs, 
rc_analysis_result
+
+
+def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> 
htm.Element:
+    """Render the delete directory form."""
+    section = htm.Block()
+
+    section.h2["Delete an empty directory"]
+
+    form.render_block(
+        section,
+        shared.finish.DeleteEmptyDirectoryForm,
+        defaults={"directory_to_delete": deletable_dirs},
+        submit_label="Delete empty directory",
+        submit_classes="btn-danger",
+        form_classes=".mb-4",
+    )
+
+    return section.collect()
+
+
+def _render_move_section(max_files_to_show: int = 10) -> htm.Element:
+    """Render the move files section with JavaScript interaction."""
+    section = htm.Block()
+
+    section.h2["Move items to a different directory"]
+    section.p[
+        "You may ",
+        htm.strong["optionally"],
+        " move files between your directories here if you want change their 
location for the final release. "
+        "Note that files with associated metadata (e.g. ",
+        htm.code[".asc"],
+        " or ",
+        htm.code[".sha512"],
+        " files) are treated as a single unit and will be moved together if 
any one of them is selected for movement.",
+    ]
+
+    section.append(htm.div("#move-error-alert.alert.alert-danger.d-none", 
role="alert", **{"aria-live": "assertive"}))
+
+    left_card = htm.Block(htm.div, classes=".card.mb-4")
+    left_card.div(".card-header.bg-light")[htm.h3(".mb-0")["Select items to 
move"]]
+    left_card.div(".card-body")[
+        htpy.input(
+            "#file-filter.form-control.mb-2",
+            type="text",
+            placeholder="Search for an item to move...",
+        ),
+        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#file-list-table-body")],
+        htm.div("#file-list-more-info.text-muted.small.mt-1"),
+        htpy.button(
+            "#select-files-toggle-button.btn.btn-outline-secondary.w-100.mt-2",
+            type="button",
+        )["Select these files"],
+    ]
+
+    right_card = htm.Block(htm.div, classes=".card.mb-4")
+    right_card.div(".card-header.bg-light")[
+        htm.h3(".mb-0")[htm.span("#selected-file-name-title")["Select a 
destination for the file"]]
+    ]
+    right_card.div(".card-body")[
+        htpy.input(
+            "#dir-filter-input.form-control.mb-2",
+            type="text",
+            placeholder="Search for a directory to move to...",
+        ),
+        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody("#dir-list-table-body")],
+        htm.div("#dir-list-more-info.text-muted.small.mt-1"),
+    ]
+
+    section.form(".atr-canary")[
+        htm.div(".row")[
+            htm.div(".col-lg-6")[left_card.collect()],
+            htm.div(".col-lg-6")[right_card.collect()],
+        ],
+        htm.div(".mb-3")[
+            htpy.label(".form-label", for_="maxFilesInput")["Items to show per 
list:"],
+            htpy.input(
+                "#max-files-input.form-control.form-control-sm.w-25",
+                type="number",
+                value=str(max_files_to_show),
+                min="1",
+            ),
+        ],
+        htm.div("#current-move-selection-info.text-muted")["Please select a 
file and a destination."],
+        htm.div[htpy.button("#confirm-move-button.btn.btn-success.mt-2", 
type="button")["Move to selected directory"]],
+    ]
+
+    return section.collect()
+
+
+async def _render_page(
+    release: sql.Release,
+    source_files_rel: list,
+    target_dirs: set,
+    deletable_dirs: list[tuple[str, str]],
+    rc_analysis: RCTagAnalysisResult,
+) -> str:
+    """Render the finish page using htm.py."""
+    page = htm.Block()
+
+    shared.distribution.html_nav(
+        page,
+        back_url=util.as_url(root.index),
+        back_anchor="Select a release",
+        phase="FINISH",
+    )
+
+    # Page heading
+    page.h1[
+        "Finish ",
+        htm.strong[release.project.short_display_name],
+        " ",
+        htm.em[release.version],
+    ]
+
+    # Release info card
+    page.append(_render_release_card(release))
+
+    # Information paragraph
+    page.p[
+        "During this phase you should distribute release artifacts to your 
package distribution networks "
+        "such as Maven Central, PyPI, or Docker Hub."
+    ]
+
+    # TODO alert
+    page.append(_render_todo_alert(release))
+
+    # Move files section
+    page.append(_render_move_section(max_files_to_show=10))
+
+    # Delete directory form
+    if deletable_dirs:
+        page.append(_render_delete_directory_form(deletable_dirs))
+
+    # Remove RC tags section
+    page.append(_render_rc_tags_section(rc_analysis))
+
+    # Custom styles
+    page_styles = """
+        .page-file-select-text {
+            vertical-align: middle;
+            margin-left: 8px;
+        }
+        .page-table-button-cell {
+            width: 1%;
+            white-space: nowrap;
+            vertical-align: middle;
+        }
+        .page-table-path-cell {
+            vertical-align: middle;
+        }
+        .page-item-selected td {
+            background-color: #e9ecef;
+            font-weight: 500;
+        }
+        .page-table-row-interactive {
+            height: 52px;
+        }
+        .page-extra-muted {
+            color: #aaaaaa;
+        }
+    """
+    page.style[markupsafe.Markup(page_styles)]
+
+    # JavaScript data
+    # TODO: Add htm.script
+    csrf_token = utils.generate_csrf()
+    page.append(
+        htpy.script(id="file-data", type="application/json")[
+            markupsafe.Markup(json.dumps([str(f) for f in 
sorted(source_files_rel)]))
+        ]
+    )
+    page.append(
+        htpy.script(id="dir-data", type="application/json")[
+            markupsafe.Markup(json.dumps(sorted([str(d) for d in 
target_dirs])))
+        ]
+    )
+    page.append(
+        htpy.script(
+            id="main-script-data",
+            src=util.static_url("js/finish-selected-move.js"),
+            **{"data-csrf-token": csrf_token},
+        )[""]
+    )
+
+    content = page.collect()
+
+    return await template.blank(
+        title=f"Finish {release.project.display_name} {release.version} ~ ATR",
+        description=f"Finish {release.project.display_name} {release.version} 
as a release preview.",
+        content=content,
+    )
+
+
+def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element:
+    """Render the remove RC tags section."""
+    section = htm.Block()
+
+    section.h2["Remove release candidate tags"]
+
+    if rc_analysis.affected_count > 0:
+        section.div(".alert.alert-info.mb-3")[
+            htm.p(".mb-3.fw-semibold")[
+                f"{rc_analysis.affected_count} / {rc_analysis.total_paths} 
paths would be affected by RC tag removal."
+            ],
+            _render_rc_preview_table(rc_analysis.affected_paths_preview) if 
rc_analysis.affected_paths_preview else "",
+        ]
+
+        form.render_block(
+            section,
+            shared.finish.RemoveRCTagsForm,
+            submit_label="Remove RC tags",
+            submit_classes="btn-warning",
+            form_classes=".mb-4.atr-canary",
+        )
+    else:
+        section.p["No paths with RC tags found to remove."]
+
+    return section.collect()
+
+
+def _render_rc_preview_table(affected_paths: list[tuple[str, str]]) -> 
htm.Element:
+    """Render the RC tags preview table."""
+    rows = [htm.tr[htm.td[original], htm.td[stripped]] for original, stripped 
in affected_paths]
+
+    return htm.div[
+        htm.p(".mb-2")["Preview of changes:"],
+        
htm.table(".table.table-sm.table-striped.border.mt-3")[htm.tbody[rows]],
+    ]
+
+
+def _render_release_card(release: sql.Release) -> htm.Element:
+    """Render the release information card."""
+    card = htm.div(".card.mb-4.shadow-sm", id=release.name)[
+        htm.div(".card-header.bg-light")[htm.h3(".card-title.mb-0")["About 
this release preview"]],
+        htm.div(".card-body")[
+            
htm.div(".d-flex.flex-wrap.gap-3.pb-3.mb-3.border-bottom.text-secondary.fs-6")[
+                htm.span(".page-preview-meta-item")[f"Revision: 
{release.latest_revision_number}"],
+                htm.span(".page-preview-meta-item")[f"Created: 
{release.created.strftime('%Y-%m-%d %H:%M:%S UTC')}"],
+            ],
+            htm.div[
+                htm.a(
+                    ".btn.btn-primary.me-2",
+                    title="Download all files",
+                    href=util.as_url(
+                        download.all_selected,
+                        project_name=release.project.name,
+                        version_name=release.version,
+                    ),
+                )[
+                    htpy.i(".bi.bi-download"),
+                    " Download all files",
+                ],
+                htm.a(
+                    ".btn.btn-secondary.me-2",
+                    title=f"Show files for {release.name}",
+                    href=util.as_url(
+                        preview.view,
+                        project_name=release.project.name,
+                        version_name=release.version,
+                    ),
+                )[
+                    htpy.i(".bi.bi-archive"),
+                    " Show files",
+                ],
+                htm.a(
+                    ".btn.btn-secondary.me-2",
+                    title=f"Show revisions for {release.name}",
+                    href=util.as_url(
+                        revisions.selected,
+                        project_name=release.project.name,
+                        version_name=release.version,
+                    ),
+                )[
+                    htpy.i(".bi.bi-clock-history"),
+                    " Show revisions",
+                ],
+                htm.a(
+                    ".btn.btn-success",
+                    title=f"Announce and distribute {release.name}",
+                    href=util.as_url(
+                        announce.selected,
+                        project_name=release.project.name,
+                        version_name=release.version,
+                    ),
+                )[
+                    htpy.i(".bi.bi-check-circle"),
+                    " Announce and distribute",
+                ],
+            ],
+        ],
+    ]
+    return card
+
+
+def _render_todo_alert(release: sql.Release) -> htm.Element:
+    """Render the TODO alert about distribution tools."""
+    return htm.div(".alert.alert-warning.mb-4", role="alert")[
+        htm.p(".fw-semibold.mb-1")["TODO"],
+        htm.p(".mb-1")[
+            "We plan to add tools to help release managers to distribute 
release artifacts on distribution networks. "
+            "Currently you must do this manually. Once you've distributed your 
release artifacts, you can ",
+            htm.a(
+                href=util.as_url(
+                    distribution.record,
+                    project=release.project.name,
+                    version=release.version,
+                )
+            )["record them on the ATR"],
+            ".",
+        ],
+    ]
+
+
+async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> 
tuple[list[pathlib.Path], set[pathlib.Path]]:
+    source_items_rel: list[pathlib.Path] = []
+    target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
+
+    async for item_rel_path in util.paths_recursive_all(latest_revision_dir):
+        current_parent = item_rel_path.parent
+        source_items_rel.append(item_rel_path)
+
+        while True:
+            target_dirs.add(current_parent)
+            if current_parent == pathlib.Path("."):
+                break
+            current_parent = current_parent.parent
+
+        item_abs_path = latest_revision_dir / item_rel_path
+        if await aiofiles.os.path.isfile(item_abs_path):
+            pass
+        elif await aiofiles.os.path.isdir(item_abs_path):
+            target_dirs.add(item_rel_path)
+
+    return source_items_rel, target_dirs
diff --git a/atr/htm.py b/atr/htm.py
index 7e62ee8..24466f5 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -240,6 +240,7 @@ class Block:
 
     @property
     def summary(self) -> BlockElementCallable:
+        self.__check_parent("summary", {"details"})
         return BlockElementCallable(self, summary)
 
     @property
diff --git a/atr/post/finish.py b/atr/post/finish.py
index 356f095..03c5ca6 100644
--- a/atr/post/finish.py
+++ b/atr/post/finish.py
@@ -15,18 +15,145 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from collections.abc import Awaitable, Callable
+import pathlib
+
+import quart
 
 import atr.blueprints.post as post
+import atr.log as log
 import atr.shared as shared
+import atr.storage as storage
 import atr.web as web
 
-type Respond = Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | 
web.WerkzeugResponse]]
-
 
 @post.committer("/finish/<project_name>/<version_name>")
[email protected](shared.finish.FinishForm)
 async def selected(
-    session: web.Committer, project_name: str, version_name: str
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str:
-    """Finish a release preview."""
-    return await shared.finish.selected(session, project_name, version_name)
+    session: web.Committer, finish_form: shared.finish.FinishForm, 
project_name: str, version_name: str
+) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
+    wants_json = 
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json"
+    respond = _respond_helper(session, project_name, version_name, wants_json)
+
+    match finish_form:
+        case shared.finish.DeleteEmptyDirectoryForm() as delete_form:
+            return await _delete_empty_directory(delete_form, session, 
project_name, version_name, respond)
+        case shared.finish.MoveFileForm() as move_form:
+            return await _move_file_to_revision(move_form, session, 
project_name, version_name, respond)
+        case shared.finish.RemoveRCTagsForm():
+            return await _remove_rc_tags(session, project_name, version_name, 
respond)
+
+
+async def _delete_empty_directory(
+    delete_form: shared.finish.DeleteEmptyDirectoryForm,
+    session: web.Committer,
+    project_name: str,
+    version_name: str,
+    respond: shared.finish.Respond,
+) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
+    dir_to_delete_rel = pathlib.Path(delete_form.directory_to_delete)
+    try:
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_member(project_name)
+            creation_error = await 
wacp.release.delete_empty_directory(project_name, version_name, 
dir_to_delete_rel)
+    except Exception:
+        log.exception(f"Unexpected error deleting directory 
{dir_to_delete_rel} for {project_name}/{version_name}")
+        return await respond(500, "An unexpected error occurred.")
+
+    if creation_error is not None:
+        return await respond(400, creation_error)
+    return await respond(200, f"Deleted empty directory 
'{dir_to_delete_rel}'.")
+
+
+async def _move_file_to_revision(
+    move_form: shared.finish.MoveFileForm,
+    session: web.Committer,
+    project_name: str,
+    version_name: str,
+    respond: shared.finish.Respond,
+) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
+    source_files_rel = [pathlib.Path(sf) for sf in move_form.source_files]
+    target_dir_rel = pathlib.Path(move_form.target_directory)
+    try:
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_member(project_name)
+            creation_error, moved_files_names, skipped_files_names = await 
wacp.release.move_file(
+                project_name, version_name, source_files_rel, target_dir_rel
+            )
+
+        if creation_error is not None:
+            return await respond(409, creation_error)
+
+        response_messages = []
+        if moved_files_names:
+            response_messages.append(f"Moved {', '.join(moved_files_names)}")
+        if skipped_files_names:
+            response_messages.append(f"Skipped {', 
'.join(skipped_files_names)} (already in target directory)")
+
+        if not response_messages:
+            if not source_files_rel:
+                return await respond(400, "No source files specified for 
move.")
+            msg = f"No files were moved. {', '.join(skipped_files_names)} 
already in '{target_dir_rel}'."
+            return await respond(200, msg)
+
+        return await respond(200, ". ".join(response_messages) + ".")
+
+    except FileNotFoundError:
+        log.exception("File not found during move operation in new revision")
+        return await respond(400, "Error: Source file not found during move 
operation.")
+    except OSError as e:
+        log.exception("Error moving file in new revision")
+        return await respond(500, f"Error moving file: {e}")
+    except Exception as e:
+        log.exception("Unexpected error during file move")
+        return await respond(500, f"ERROR: {e!s}")
+
+
+async def _remove_rc_tags(
+    session: web.Committer,
+    project_name: str,
+    version_name: str,
+    respond: shared.finish.Respond,
+) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
+    try:
+        async with storage.write(session) as write:
+            wacp = await write.as_project_committee_member(project_name)
+            creation_error, renamed_count, error_messages = await 
wacp.release.remove_rc_tags(
+                project_name, version_name
+            )
+
+        if creation_error is not None:
+            return await respond(409, creation_error)
+
+        if error_messages:
+            status_ok = renamed_count > 0
+            # TODO: Ideally HTTP would have a general mixed status, like 207 
but for anything
+            http_status = 200 if status_ok else 500
+            msg = f"RC tags removed for {renamed_count} item(s) with some 
errors: {'; '.join(error_messages)}"
+            return await respond(http_status, msg)
+
+        if renamed_count > 0:
+            return await respond(200, f"Successfully removed RC tags from 
{renamed_count} item(s).")
+
+        return await respond(200, "No items required RC tag removal or no 
changes were made.")
+
+    except Exception as e:
+        return await respond(500, f"Unexpected error: {e!s}")
+
+
+def _respond_helper(
+    session: web.Committer, project_name: str, version_name: str, wants_json: 
bool
+) -> shared.finish.Respond:
+    """Create a response helper function for the finish route."""
+    import atr.get as get
+
+    async def respond(
+        http_status: int,
+        msg: str,
+    ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
+        ok = http_status < 300
+        if wants_json:
+            return quart.jsonify(ok=ok, message=msg), http_status
+        await quart.flash(msg, "success" if ok else "error")
+        return await session.redirect(get.finish.selected, 
project_name=project_name, version_name=version_name)
+
+    return respond
diff --git a/atr/shared/finish.py b/atr/shared/finish.py
index 59389e7..70cf00f 100644
--- a/atr/shared/finish.py
+++ b/atr/shared/finish.py
@@ -15,411 +15,50 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import dataclasses
 import pathlib
 from collections.abc import Awaitable, Callable
-from typing import Any
+from typing import Annotated, Literal
 
-import aiofiles.os
-import asfquart.base as base
-import quart
-import werkzeug.datastructures as datastructures
-import wtforms
-import wtforms.fields as fields
+import pydantic
 
-import atr.analysis as analysis
-import atr.db as db
-import atr.forms as forms
-import atr.log as log
-import atr.mapping as mapping
-import atr.models.sql as sql
-import atr.storage as storage
-import atr.template as template
-import atr.util as util
+import atr.form as form
 import atr.web as web
 
 type Respond = Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | 
web.WerkzeugResponse]]
 
+type DELETE_DIR = Literal["DELETE_DIR"]
+type MOVE_FILE = Literal["MOVE_FILE"]
+type REMOVE_RC_TAGS = Literal["REMOVE_RC_TAGS"]
 
-class DeleteEmptyDirectoryForm(forms.Typed):
-    """Form for deleting an empty directory within a preview revision."""
 
-    directory_to_delete = forms.select("Directory to delete")
-    submit_delete_empty_dir = forms.submit("Delete directory")
+class DeleteEmptyDirectoryForm(form.Form):
+    variant: DELETE_DIR = form.value(DELETE_DIR)
+    directory_to_delete: str = form.label("Directory to delete", 
widget=form.Widget.SELECT)
 
 
-class MoveFileForm(forms.Typed):
-    """Form for moving one or more files within a preview revision."""
+class MoveFileForm(form.Form):
+    variant: MOVE_FILE = form.value(MOVE_FILE)
+    source_files: form.StrList = form.label("Files to move")
+    target_directory: str = form.label("Target directory")
 
-    source_files = forms.multiple("Files to move")
-    target_directory = forms.select("Target directory", validate_choice=False)
-    submit = wtforms.SubmitField("Move file")
+    @pydantic.model_validator(mode="after")
+    def validate_move(self) -> "MoveFileForm":
+        if not self.source_files:
+            raise ValueError("Please select at least one file to move.")
 
-    def validate_source_files(self, field: fields.SelectMultipleField) -> None:
-        if not field.data or len(field.data) == 0:
-            raise wtforms.validators.ValidationError("Please select at least 
one file to move.")
+        source_paths = [pathlib.Path(sf) for sf in self.source_files]
+        target_dir = pathlib.Path(self.target_directory)
+        for source_path in source_paths:
+            if source_path.parent == target_dir:
+                raise ValueError(f"Target directory cannot be the same as the 
source directory for {source_path.name}.")
+        return self
 
-    def validate_target_directory(self, field: wtforms.Field) -> None:
-        # This validation runs only if both fields have data
-        if self.source_files.data and field.data:
-            source_paths = [pathlib.Path(sf) for sf in self.source_files.data]
-            target_dir = pathlib.Path(field.data)
-            for source_path in source_paths:
-                if source_path.parent == target_dir:
-                    raise wtforms.validators.ValidationError(
-                        f"Target directory cannot be the same as the source 
directory for {source_path.name}."
-                    )
 
+class RemoveRCTagsForm(form.Empty):
+    variant: REMOVE_RC_TAGS = form.value(REMOVE_RC_TAGS)
 
-class RemoveRCTagsForm(forms.Typed):
-    submit_remove_rc_tags = forms.submit("Remove RC tags")
 
-
[email protected]
-class ProcessFormDataArgs:
-    formdata: datastructures.MultiDict
-    session: web.Committer
-    project_name: str
-    version_name: str
-    move_form: MoveFileForm
-    delete_dir_form: DeleteEmptyDirectoryForm
-    remove_rc_tags_form: RemoveRCTagsForm
-    can_move: bool
-    wants_json: bool
-    respond: Respond
-
-
[email protected]
-class RCTagAnalysisResult:
-    affected_paths_preview: list[tuple[str, str]]
-    affected_count: int
-    total_paths: int
-
-
-async def selected(
-    session: web.Committer, project_name: str, version_name: str
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str:
-    """Finish a release preview."""
-    await session.check_access(project_name)
-
-    wants_json = 
quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == 
"application/json"
-
-    async def respond(
-        http_status: int,
-        msg: str,
-    ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
-        """Helper to respond with JSON or flash message and redirect."""
-        # nonlocal session
-        # nonlocal project_name
-        # nonlocal version_name
-        # nonlocal wants_json
-
-        ok = http_status < 300
-        if wants_json:
-            return quart.jsonify(ok=ok, message=msg), http_status
-        await quart.flash(msg, "success" if ok else "error")
-        return await session.redirect(get.finish.selected, 
project_name=project_name, version_name=version_name)
-
-    async with db.session() as data:
-        release = await data.release(
-            project_name=project_name,
-            version=version_name,
-            _committee=True,
-        ).demand(base.ASFQuartException("Release does not exist", 
errorcode=404))
-    if release.phase != sql.ReleasePhase.RELEASE_PREVIEW:
-        return await mapping.release_as_redirect(session, release)
-    user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
-
-    latest_revision_dir = util.release_directory(release)
-    try:
-        source_files_rel, target_dirs = await 
_sources_and_targets(latest_revision_dir)
-    except FileNotFoundError:
-        import atr.get as get
-
-        await quart.flash("Preview revision directory not found.", "error")
-        return await session.redirect(get.root.index)
-
-    formdata = None
-    if quart.request.method == "POST":
-        formdata = await quart.request.form
-
-    move_form = await MoveFileForm.create_form(
-        data=formdata if (formdata and formdata.get("form_action") != 
"create_dir") else None
-    )
-    delete_dir_form = await DeleteEmptyDirectoryForm.create_form(
-        data=formdata if (formdata and ("submit_delete_empty_dir" in 
formdata)) else None
-    )
-    remove_rc_tags_form = await RemoveRCTagsForm.create_form(
-        data=formdata if (formdata and ("submit_remove_rc_tags" in formdata)) 
else None
-    )
-
-    # Populate choices dynamically for both GET and POST
-    move_form.source_files.choices = sorted([(str(p), str(p)) for p in 
source_files_rel])
-    move_form.target_directory.choices = sorted([(str(d), str(d)) for d in 
target_dirs])
-    can_move = (len(target_dirs) > 1) and (len(source_files_rel) > 0)
-    delete_dir_form.directory_to_delete.choices = await 
_deletable_choices(latest_revision_dir, target_dirs)
-
-    if formdata:
-        pfd_args = ProcessFormDataArgs(
-            formdata=formdata,
-            session=session,
-            project_name=project_name,
-            version_name=version_name,
-            move_form=move_form,
-            delete_dir_form=delete_dir_form,
-            remove_rc_tags_form=remove_rc_tags_form,
-            can_move=can_move,
-            wants_json=wants_json,
-            respond=respond,
-        )
-        result = await _submission_process(pfd_args)
-        if result is not None:
-            return result
-
-    rc_analysis_result = await _analyse_rc_tags(latest_revision_dir)
-    return await template.render(
-        "finish-selected.html",
-        asf_id=session.uid,
-        server_domain=session.app_host.split(":", 1)[0],
-        server_host=session.app_host,
-        release=release,
-        source_files=sorted(source_files_rel),
-        form=move_form,
-        delete_dir_form=delete_dir_form,
-        user_ssh_keys=user_ssh_keys,
-        target_dirs=sorted(list(target_dirs)),
-        max_files_to_show=10,
-        remove_rc_tags_form=remove_rc_tags_form,
-        rc_affected_paths_preview=rc_analysis_result.affected_paths_preview,
-        rc_affected_count=rc_analysis_result.affected_count,
-        rc_total_paths=rc_analysis_result.total_paths,
-    )
-
-
-async def _analyse_rc_tags(latest_revision_dir: pathlib.Path) -> 
RCTagAnalysisResult:
-    r = RCTagAnalysisResult(
-        affected_paths_preview=[],
-        affected_count=0,
-        total_paths=0,
-    )
-
-    if not latest_revision_dir.exists():
-        return r
-
-    async for p_rel in util.paths_recursive_all(latest_revision_dir):
-        r.total_paths += 1
-        original_path_str = str(p_rel)
-        stripped_path_str = str(analysis.candidate_removed(p_rel))
-        if original_path_str == stripped_path_str:
-            continue
-        r.affected_count += 1
-        if len(r.affected_paths_preview) >= 5:
-            # Can't break here, because we need to update the counts
-            continue
-        r.affected_paths_preview.append((original_path_str, stripped_path_str))
-
-    return r
-
-
-async def _deletable_choices(latest_revision_dir: pathlib.Path, target_dirs: 
set[pathlib.Path]) -> Any:
-    # This should be -> list[tuple[str, str]], but that causes pyright to 
complain incorrectly
-    # Details in 
pyright/dist/dist/typeshed-fallback/stubs/WTForms/wtforms/fields/choices.pyi
-    # _Choice: TypeAlias = tuple[Any, str] | tuple[Any, str, dict[str, Any]]
-    # Then it wants us to use list[_Choice] (= list[tuple[Any, str]])
-    # But it says, incorrectly, that list[tuple[str, str]] is not a 
list[_Choice]
-    # This mistake is not made by mypy
-    empty_deletable_dirs: list[pathlib.Path] = []
-    if latest_revision_dir.exists():
-        for d_rel in target_dirs:
-            if d_rel == pathlib.Path("."):
-                # Disallow deletion of the root directory
-                continue
-            d_full = latest_revision_dir / d_rel
-            if await aiofiles.os.path.isdir(d_full) and not await 
aiofiles.os.listdir(d_full):
-                empty_deletable_dirs.append(d_rel)
-    return sorted([(str(p), str(p)) for p in empty_deletable_dirs])
-
-
-async def _delete_empty_directory(
-    dir_to_delete_rel: pathlib.Path,
-    session: web.Committer,
-    project_name: str,
-    version_name: str,
-    respond: Respond,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
-    try:
-        async with storage.write(session) as write:
-            wacp = await write.as_project_committee_member(project_name)
-            creation_error = await 
wacp.release.delete_empty_directory(project_name, version_name, 
dir_to_delete_rel)
-    except Exception:
-        log.exception(f"Unexpected error deleting directory 
{dir_to_delete_rel} for {project_name}/{version_name}")
-        return await respond(500, "An unexpected error occurred.")
-
-    if creation_error is not None:
-        return await respond(400, creation_error)
-    return await respond(200, f"Deleted empty directory 
'{dir_to_delete_rel}'.")
-
-
-async def _move_file_to_revision(
-    source_files_rel: list[pathlib.Path],
-    target_dir_rel: pathlib.Path,
-    session: web.Committer,
-    project_name: str,
-    version_name: str,
-    respond: Respond,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
-    try:
-        async with storage.write(session) as write:
-            wacp = await write.as_project_committee_member(project_name)
-            creation_error, moved_files_names, skipped_files_names = await 
wacp.release.move_file(
-                project_name, version_name, source_files_rel, target_dir_rel
-            )
-
-        if creation_error is not None:
-            return await respond(409, creation_error)
-
-        response_messages = []
-        if moved_files_names:
-            response_messages.append(f"Moved {', '.join(moved_files_names)}")
-        if skipped_files_names:
-            response_messages.append(f"Skipped {', 
'.join(skipped_files_names)} (already in target directory)")
-
-        if not response_messages:
-            if not source_files_rel:
-                return await respond(400, "No source files specified for 
move.")
-            msg = f"No files were moved. {', '.join(skipped_files_names)} 
already in '{target_dir_rel}'."
-            return await respond(200, msg)
-
-        return await respond(200, ". ".join(response_messages) + ".")
-
-    except FileNotFoundError:
-        log.exception("File not found during move operation in new revision")
-        return await respond(400, "Error: Source file not found during move 
operation.")
-    except OSError as e:
-        log.exception("Error moving file in new revision")
-        return await respond(500, f"Error moving file: {e}")
-    except Exception as e:
-        log.exception("Unexpected error during file move")
-        return await respond(500, f"ERROR: {e!s}")
-
-
-async def _remove_rc_tags(
-    session: web.Committer,
-    project_name: str,
-    version_name: str,
-    respond: Respond,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse:
-    try:
-        async with storage.write(session) as write:
-            wacp = await write.as_project_committee_member(project_name)
-            creation_error, renamed_count, error_messages = await 
wacp.release.remove_rc_tags(
-                project_name, version_name
-            )
-
-        if creation_error is not None:
-            return await respond(409, creation_error)
-
-        if error_messages:
-            status_ok = renamed_count > 0
-            # TODO: Ideally HTTP would have a general mixed status, like 207 
but for anything
-            http_status = 200 if status_ok else 500
-            msg = f"RC tags removed for {renamed_count} item(s) with some 
errors: {'; '.join(error_messages)}"
-            return await respond(http_status, msg)
-
-        if renamed_count > 0:
-            return await respond(200, f"Successfully removed RC tags from 
{renamed_count} item(s).")
-
-        return await respond(200, "No items required RC tag removal or no 
changes were made.")
-
-    except Exception as e:
-        return await respond(500, f"Unexpected error: {e!s}")
-
-
-async def _sources_and_targets(latest_revision_dir: pathlib.Path) -> 
tuple[list[pathlib.Path], set[pathlib.Path]]:
-    source_items_rel: list[pathlib.Path] = []
-    target_dirs: set[pathlib.Path] = {pathlib.Path(".")}
-
-    async for item_rel_path in util.paths_recursive_all(latest_revision_dir):
-        current_parent = item_rel_path.parent
-        source_items_rel.append(item_rel_path)
-
-        while True:
-            target_dirs.add(current_parent)
-            if current_parent == pathlib.Path("."):
-                break
-            current_parent = current_parent.parent
-
-        item_abs_path = latest_revision_dir / item_rel_path
-        if await aiofiles.os.path.isfile(item_abs_path):
-            pass
-        elif await aiofiles.os.path.isdir(item_abs_path):
-            target_dirs.add(item_rel_path)
-
-    return source_items_rel, target_dirs
-
-
-async def _submission_process(
-    args: ProcessFormDataArgs,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str | None:
-    delete_empty_directory = "submit_delete_empty_dir" in args.formdata
-    remove_rc_tags = "submit_remove_rc_tags" in args.formdata
-    move_file = ("source_files" in args.formdata) and ("target_directory" in 
args.formdata)
-
-    if delete_empty_directory:
-        return await _submission_process_delete_empty_directory(args)
-
-    if remove_rc_tags:
-        return await _submission_process_remove_rc_tags(args)
-
-    if move_file:
-        return await _submission_process_move_file(args)
-
-    return None
-
-
-async def _submission_process_delete_empty_directory(
-    args: ProcessFormDataArgs,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str | None:
-    if await args.delete_dir_form.validate_on_submit():
-        dir_to_delete_str = args.delete_dir_form.directory_to_delete.data
-        return await _delete_empty_directory(
-            pathlib.Path(dir_to_delete_str), args.session, args.project_name, 
args.version_name, args.respond
-        )
-    elif args.wants_json:
-        error_messages = []
-        for field_name_str, error_list in args.delete_dir_form.errors.items():
-            field_obj = getattr(args.delete_dir_form, field_name_str, None)
-            label_text = field_name_str.replace("_", " ").title()
-            if field_obj and hasattr(field_obj, "label") and field_obj.label:
-                label_text = field_obj.label.text
-            error_messages.append(f"{label_text}: {', '.join(error_list)}")
-        error_msg = "; ".join(error_messages)
-        return await args.respond(400, error_msg or "Invalid input.")
-    return None
-
-
-async def _submission_process_move_file(
-    args: ProcessFormDataArgs,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str | None:
-    source_files_data = args.formdata.getlist("source_files")
-    target_dir_data = args.formdata.get("target_directory")
-
-    if not source_files_data or not target_dir_data:
-        return await args.respond(400, "Missing source file(s) or target 
directory.")
-    source_files_rel = [pathlib.Path(sf) for sf in source_files_data]
-    target_dir_rel = pathlib.Path(target_dir_data)
-    if not source_files_rel:
-        return await args.respond(400, "No source files selected.")
-    return await _move_file_to_revision(
-        source_files_rel, target_dir_rel, args.session, args.project_name, 
args.version_name, args.respond
-    )
-
-
-async def _submission_process_remove_rc_tags(
-    args: ProcessFormDataArgs,
-) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse | str | None:
-    if await args.remove_rc_tags_form.validate_on_submit():
-        return await _remove_rc_tags(args.session, args.project_name, 
args.version_name, args.respond)
-    elif args.wants_json:
-        return await args.respond(400, "Invalid request for RC tag removal.")
-    return None
+type FinishForm = Annotated[
+    DeleteEmptyDirectoryForm | MoveFileForm | RemoveRCTagsForm,
+    form.DISCRIMINATOR,
+]
diff --git a/atr/static/js/finish-selected-move.js 
b/atr/static/js/finish-selected-move.js
index 3f33281..9bfb1da 100644
--- a/atr/static/js/finish-selected-move.js
+++ b/atr/static/js/finish-selected-move.js
@@ -310,6 +310,7 @@ function moveFiles(files, dest, csrfToken, signal) {
     return __awaiter(this, void 0, void 0, function* () {
         const formData = new FormData();
         formData.append("csrf_token", csrfToken);
+        formData.append("variant", "MOVE_FILE");
         for (const file of files) {
             formData.append("source_files", file);
         }
diff --git a/atr/static/js/finish-selected-move.js.map 
b/atr/static/js/finish-selected-move.js.map
index 9a4dc2c..4c7c9c4 100644
--- a/atr/static/js/finish-selected-move.js.map
+++ b/atr/static/js/finish-selected-move.js.map
@@ -1 +1 @@
-{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;;;;;;;;;;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IACrB,iBAAiB,EAAE,qBAAqB;IACxC,wBAAwB,EAAE,6BAA6B;IACvD,OAAO,EAAE,UAAU;IACnB,cAAc,EAAE,kBAAkB;IAClC,eAAe,EAAE,oBAAoB;IACrC,gBAAgB,EAAE,qBAAqB;IACvC,UAAU,EAAE,kBAAkB;IAC9B,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE,
 [...]
+{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;;;;;;;;;;AAEb,IAAK,QAGJ;AAHD,WAAK,QAAQ;IACT,yBAAa,CAAA;IACb,uBAAW,CAAA;AACf,CAAC,EAHI,QAAQ,KAAR,QAAQ,QAGZ;AAED,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,CAAC;IACrB,iBAAiB,EAAE,qBAAqB;IACxC,wBAAwB,EAAE,6BAA6B;IACvD,OAAO,EAAE,UAAU;IACnB,cAAc,EAAE,kBAAkB;IAClC,eAAe,EAAE,oBAAoB;IACrC,gBAAgB,EAAE,qBAAqB;IACvC,UAAU,EAAE,kBAAkB;IAC9B,QAAQ,EAAE,WAAW;IACrB,UAAU,EAAE,
 [...]
diff --git a/atr/static/ts/finish-selected-move.ts 
b/atr/static/ts/finish-selected-move.ts
index 050983d..596d042 100644
--- a/atr/static/ts/finish-selected-move.ts
+++ b/atr/static/ts/finish-selected-move.ts
@@ -374,6 +374,7 @@ function isErrorResponse(data: unknown): data is 
ErrorResponse {
 async function moveFiles(files: readonly string[], dest: string, csrfToken: 
string, signal?: AbortSignal): Promise<MoveResult> {
     const formData = new FormData();
     formData.append("csrf_token", csrfToken);
+    formData.append("variant", "MOVE_FILE");
     for (const file of files) {
         formData.append("source_files", file);
     }
diff --git a/atr/templates/finish-selected.html 
b/atr/templates/finish-selected.html
deleted file mode 100644
index a381335..0000000
--- a/atr/templates/finish-selected.html
+++ /dev/null
@@ -1,231 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
-  Finish {{ release.project.display_name }} {{ release.version }} ~ ATR
-{% endblock title %}
-
-{% block description %}
-  Finish {{ release.project.display_name }} {{ release.version }} as a release 
preview.
-{% endblock description %}
-
-{% block stylesheets %}
-  {{ super() }}
-  <style>
-      .page-file-select-text {
-          vertical-align: middle;
-          margin-left: 8px;
-      }
-
-      .page-table-button-cell {
-          width: 1%;
-          white-space: nowrap;
-          vertical-align: middle;
-      }
-
-      .page-table-path-cell {
-          vertical-align: middle;
-      }
-
-      .page-item-selected td {
-          background-color: #e9ecef;
-          font-weight: 500;
-      }
-
-      .page-table-row-interactive {
-          height: 52px;
-      }
-
-      .page-extra-muted {
-          color: #aaaaaa;
-      }
-  </style>
-{% endblock stylesheets %}
-
-{% block content %}
-  <p class="d-flex justify-content-between align-items-center">
-    <a href="{{ as_url(get.root.index) }}" class="atr-back-link">← Back to 
Select a release</a>
-    <span>
-      <span class="atr-phase-symbol-other">①</span>
-      <span class="atr-phase-arrow">→</span>
-      <span class="atr-phase-symbol-other">②</span>
-      <span class="atr-phase-arrow">→</span>
-      <strong class="atr-phase-three atr-phase-symbol">③</strong>
-      <span class="atr-phase-three atr-phase-label">FINISH</span>
-    </span>
-  </p>
-
-  <h1>
-    Finish <strong>{{ release.project.short_display_name }}</strong> <em>{{ 
release.version }}</em>
-  </h1>
-
-  <div id="{{ release.name }}" class="card mb-4 shadow-sm">
-    <div class="card-header bg-light">
-      <h3 class="card-title mb-0">About this release preview</h3>
-    </div>
-    <div class="card-body">
-      <div class="d-flex flex-wrap gap-3 pb-3 mb-3 border-bottom 
text-secondary fs-6">
-        <span class="page-preview-meta-item">Revision: {{ 
release.latest_revision_number }}</span>
-        <span class="page-preview-meta-item">Created: {{ 
release.created.strftime("%Y-%m-%d %H:%M:%S UTC") }}</span>
-      </div>
-      <div>
-        <a title="Download all files"
-           href="{{ as_url(get.download.all_selected, 
project_name=release.project.name, version_name=release.version) }}"
-           class="btn btn-primary me-2">
-          <i class="bi bi-download"></i>
-          Download all files
-        </a>
-        <a title="Show files for {{ release.name }}"
-           href="{{ as_url(get.preview.view, 
project_name=release.project.name, version_name=release.version) }}"
-           class="btn btn-secondary me-2">
-          <i class="bi bi-archive"></i>
-          Show files
-        </a>
-        <a title="Show revisions for {{ release.name }}"
-           href="{{ as_url(get.revisions.selected, 
project_name=release.project.name, version_name=release.version) }}"
-           class="btn btn-secondary me-2">
-          <i class="bi bi-clock-history"></i>
-          Show revisions
-        </a>
-        <a title="Announce and distribute {{ release.name }}"
-           href="{{ as_url(get.announce.selected, 
project_name=release.project.name, version_name=release.version) }}"
-           class="btn btn-success">
-          <i class="bi bi-check-circle"></i>
-          Announce and distribute
-        </a>
-      </div>
-    </div>
-  </div>
-
-  <p>
-    During this phase you should distribute release artifacts to your package 
distribution networks such as Maven Central, PyPI, or Docker Hub.
-  </p>
-
-  <div class="alert alert-warning mb-4" role="alert">
-    <p class="fw-semibold mb-1">TODO</p>
-    <p class="mb-1">
-      We plan to add tools to help release managers to distribute release 
artifacts on distribution networks. Currently you must do this manually. Once 
you've distributed your release artifacts, you can <a href="{{ 
as_url(get.distribution.record, project=release.project.name, 
version=release.version) }}">record them on the ATR</a>.
-    </p>
-  </div>
-
-  <h2>Move items to a different directory</h2>
-  <p>
-    You may <strong>optionally</strong> move files between your directories 
here if you want change their location for the final release. Note that files 
with associated metadata (e.g. <code>.asc</code> or <code>.sha512</code> files) 
are treated as a single unit and will be moved together if any one of them is 
selected for movement.
-  </p>
-  <div id="move-error-alert"
-       class="alert alert-danger d-none"
-       role="alert"
-       aria-live="assertive"></div>
-  <form class="atr-canary">
-    <div class="row">
-      <div class="col-lg-6">
-        <div class="card mb-4">
-          <div class="card-header bg-light">
-            <h3 class="mb-0">Select items to move</h3>
-          </div>
-          <div class="card-body">
-            <input type="text"
-                   id="file-filter"
-                   class="form-control mb-2"
-                   placeholder="Search for an item to move..." />
-            <table class="table table-sm table-striped border mt-3">
-              <tbody id="file-list-table-body">
-              </tbody>
-            </table>
-            <div id="file-list-more-info" class="text-muted small mt-1"></div>
-            <button type="button"
-                    id="select-files-toggle-button"
-                    class="btn btn-outline-secondary w-100 mt-2">Select these 
files</button>
-          </div>
-        </div>
-      </div>
-      <div class="col-lg-6">
-        <div class="card mb-4">
-          <div class="card-header bg-light">
-            <h3 class="mb-0">
-              <span id="selected-file-name-title">Select a destination for the 
file</span>
-            </h3>
-          </div>
-          <div class="card-body">
-            <input type="text"
-                   id="dir-filter-input"
-                   class="form-control mb-2"
-                   placeholder="Search for a directory to move to..." />
-            <table class="table table-sm table-striped border mt-3">
-              <tbody id="dir-list-table-body">
-              </tbody>
-            </table>
-            <div id="dir-list-more-info" class="text-muted small mt-1"></div>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <div>
-      <div class="mb-3">
-        <label for="maxFilesInput" class="form-label">Items to show per 
list:</label>
-        <input type="number"
-               class="form-control form-control-sm w-25"
-               id="max-files-input"
-               value="{{ max_files_to_show }}"
-               min="1" />
-      </div>
-      <div id="current-move-selection-info" class="text-muted">Please select a 
file and a destination.</div>
-      <button type="button" id="confirm-move-button" class="btn btn-success 
mt-2">Move to selected directory</button>
-    </div>
-  </form>
-  {% if delete_dir_form.directory_to_delete.choices %}
-    <h2>Delete an empty directory</h2>
-    <form method="post" class="mb-4">
-      {{ delete_dir_form.hidden_tag() }}
-      <div class="input-group">
-        {{ delete_dir_form.directory_to_delete(class="form-select") }}
-        {{ delete_dir_form.submit_delete_empty_dir(class="btn btn-danger") }}
-      </div>
-      {{ forms.errors(delete_dir_form.directory_to_delete, 
classes="text-danger small mt-1") }}
-    </form>
-  {% endif %}
-
-  <h2>Remove release candidate tags</h2>
-  {% if rc_affected_count > 0 %}
-    <div class="alert alert-info mb-3">
-      <p class="mb-3 fw-semibold">
-        {{ rc_affected_count }} / {{ rc_total_paths }} paths would be affected 
by RC tag removal.
-      </p>
-      {% if rc_affected_paths_preview %}
-        <p class="mb-2">Preview of first {{ rc_affected_paths_preview | length 
}} changes:</p>
-        <table class="table table-sm table-striped border mt-3">
-          <tbody>
-            {% for original, stripped in rc_affected_paths_preview %}
-              <tr>
-                <td>{{ original }}</td>
-                <td>{{ stripped }}</td>
-              </tr>
-            {% endfor %}
-          </tbody>
-        </table>
-      {% endif %}
-    </div>
-    <form method="post" class="mb-4 atr-canary">
-      {{ remove_rc_tags_form.hidden_tag() }}
-      {{ remove_rc_tags_form.submit_remove_rc_tags(class="btn btn-warning") }}
-    </form>
-  {% else %}
-    <p>No paths with RC tags found to remove.</p>
-  {% endif %}
-{% endblock content %}
-
-{% block javascripts %}
-  {{ super() }}
-  {# If we don't turn the linter off, it breaks the Jinja2 variables #}
-  {# djlint:off #}
-  <script id="file-data" type="application/json">
-      {{ source_files | tojson | safe }}
-</script>
-  <script id="dir-data" type="application/json">
-      {{ target_dirs | tojson | safe }}
-</script>
-  {# djlint:on #}
-  <script id="main-script-data"
-          src="{{ static_url('js/finish-selected-move.js') }}"
-          data-csrf-token="{{ form.csrf_token.current_token }}"></script>
-{% endblock javascripts %}


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

Reply via email to