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]