This is an automated email from the ASF dual-hosted git repository. arm pushed a commit to branch moving_file_ui_to_compose in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 86d516e2563d0d35370972f6cd56a26969b6ae86 Author: Alastair McFarlane <[email protected]> AuthorDate: Thu Mar 26 14:51:30 2026 +0000 #931 - moving file planner to compose phase --- atr/post/__init__.py | 2 + atr/post/compose.py | 108 ++++++++++++++++++++++++++++++++++++ atr/shared/web.py | 114 ++++++++++++++++++++++++++++++++++++++ atr/templates/check-selected.html | 8 +++ 4 files changed, 232 insertions(+) diff --git a/atr/post/__init__.py b/atr/post/__init__.py index 5885f7ac..4dae1d16 100644 --- a/atr/post/__init__.py +++ b/atr/post/__init__.py @@ -18,6 +18,7 @@ from typing import Final, Literal import atr.post.announce as announce +import atr.post.compose as compose import atr.post.distribution as distribution import atr.post.draft as draft import atr.post.finish as finish @@ -40,6 +41,7 @@ ROUTES_MODULE: Final[Literal[True]] = True __all__ = [ "announce", + "compose", "distribution", "draft", "finish", diff --git a/atr/post/compose.py b/atr/post/compose.py new file mode 100644 index 00000000..640a5e6e --- /dev/null +++ b/atr/post/compose.py @@ -0,0 +1,108 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +from collections.abc import Awaitable, Callable +from typing import Literal + +import quart + +import atr.blueprints.post as post +import atr.log as log +import atr.models.safe as safe +import atr.shared as shared +import atr.storage as storage +import atr.web as web + + [email protected] +async def selected( + session: web.Committer, + _compose: Literal["compose"], + project_key: safe.ProjectKey, + version_key: safe.VersionKey, + move_form: shared.finish.MoveFileForm, +) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse: + """ + URL: /compose/<project_key>/<version_key> + """ + wants_json = quart.request.accept_mimetypes.best_match(["application/json", "text/html"]) == "application/json" + respond = _respond_helper(session, project_key, version_key, wants_json) + + return await _move_file_to_revision(move_form, session, project_key, version_key, respond) + + +async def _move_file_to_revision( + move_form: shared.finish.MoveFileForm, + session: web.Committer, + project_key: safe.ProjectKey, + version_key: safe.VersionKey, + respond: Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | web.WerkzeugResponse]], +) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse: + source_files_rel = move_form.source_files + target_dir_rel = move_form.target_directory + try: + async with storage.write(session) as write: + wacp = await write.as_project_committee_member(project_key) + creation_error, moved_files_names, skipped_files_names = await wacp.release.move_file( + project_key, version_key, 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}") + + +def _respond_helper( + session: web.Committer, project_key: safe.ProjectKey, version_key: safe.VersionKey, wants_json: bool +) -> Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | web.WerkzeugResponse]]: + """Create a response helper function for the compose 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.compose.selected, project_key=str(project_key), version_key=str(version_key)) + + return respond diff --git a/atr/shared/web.py b/atr/shared/web.py index 51b06142..8707ff10 100644 --- a/atr/shared/web.py +++ b/atr/shared/web.py @@ -14,9 +14,12 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. +import pathlib from typing import TYPE_CHECKING +import aiofiles.os import htpy +import quart_wtf.utils as utils import atr.db as db import atr.db.interaction as interaction @@ -170,6 +173,23 @@ async def check( blocker_errors = await interaction.has_blocker_checks(release, revision_number) checks_summary_html = render_checks_summary(info, release.safe_project_key, release.safe_version_key) + move_file_html = _render_move_section(10) + + csrf_token = utils.generate_csrf() + # Should be already validated, but check again + latest_revision_dir = paths.release_directory(release) + source_files_rel, target_dirs = await _sources_and_targets(latest_revision_dir) + safe_source_files_rel = [util.validate_path(f).as_posix() for f in sorted(source_files_rel)] + safe_target_dirs = [util.validate_path(d).as_posix() for d in sorted(target_dirs)] + scripts = htpy.fragment[ + htpy.script(id="file-data", type="application/json")[util.json_for_script_element(safe_source_files_rel)], + htpy.script(id="dir-data", type="application/json")[util.json_for_script_element(safe_target_dirs)], + htpy.script( + id="main-script-data", + src=util.static_url("js/ts/finish-selected-move.js"), + **{"data-csrf-token": csrf_token}, + )[""], + ] return await template.render( "check-selected.html", @@ -208,6 +228,8 @@ async def check( can_vote=can_vote, can_resolve=can_resolve, checks_summary_html=checks_summary_html, + move_file_html=move_file_html, + scripts=str(scripts), ) @@ -268,6 +290,98 @@ def _checker_display_name(checker: str) -> str: return checker.removeprefix("atr.tasks.checks.").replace("_", " ").replace(".", " ").title() +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 _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 + + def _warnings_from_vote_result(vote_task: sql.Task | None) -> list[str]: # TODO: Replace this with a schema.Strict model # But we'd still need to do some of this parsing and validation diff --git a/atr/templates/check-selected.html b/atr/templates/check-selected.html index 95494023..f27aebc1 100644 --- a/atr/templates/check-selected.html +++ b/atr/templates/check-selected.html @@ -175,6 +175,11 @@ <a class="btn btn-primary" href="{{ as_url(get.distribution.stage_record, project_key=release.project.key, version_key=release.version) }}">Record a manual distribution</a> </p> + <div id="move-files-container"> + {% if move_file_html %} + {{ move_file_html|safe }} + {% endif %} + </div> <h2 id="more-actions">More actions</h2> <h3 id="ignored-checks" class="mt-4">Ignored checks</h3> {% if info.ignored_errors or info.ignored_warnings %} @@ -248,4 +253,7 @@ {% block javascripts %} {{ super() }} <script src="{{ static_url('js/src/ongoing-tasks-poll.js') }}"></script> + {% if scripts %} + {{ scripts }} + {% endif %} {% endblock javascripts %} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
