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]

Reply via email to