This is an automated email from the ASF dual-hosted git repository. arm pushed a commit to branch arm in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit f6a4841cc6916704a47c4a7d59186470a91da623 Author: Alastair McFarlane <[email protected]> AuthorDate: Fri Mar 27 17:22:56 2026 +0000 #931 - tidy up file planner code --- atr/get/compose.py | 293 ++++++++++++++++++- atr/get/finish.py | 108 +------ atr/post/compose.py | 9 +- atr/post/finish.py | 46 --- atr/shared/__init__.py | 2 + atr/shared/{finish.py => compose.py} | 19 +- atr/shared/finish.py | 23 +- atr/shared/web.py | 316 --------------------- atr/static/js/ts/create-a-jwt.js | 1 + atr/static/js/ts/create-a-jwt.js.map | 2 +- atr/static/js/ts/finish-selected-move.js.map | 1 - .../ts/{finish-selected-move.js => move-files.js} | 2 +- atr/static/js/ts/move-files.js.map | 1 + .../ts/{finish-selected-move.ts => move-files.ts} | 0 tests/e2e/announce/test_get.py | 13 - tests/e2e/compose/conftest.py | 2 +- tests/e2e/compose/test_get.py | 13 + tests/e2e/sbom/test_post.py | 8 +- 18 files changed, 326 insertions(+), 533 deletions(-) diff --git a/atr/get/compose.py b/atr/get/compose.py index e5d552d6..906ba499 100644 --- a/atr/get/compose.py +++ b/atr/get/compose.py @@ -14,19 +14,35 @@ # 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, Literal -from typing import Literal - +import aiofiles.os import asfquart.base as base +import htpy +from quart_wtf import utils import atr.blueprints.get as get import atr.db as db +import atr.db.interaction as interaction +import atr.form as form +import atr.htm as htm import atr.mapping as mapping +import atr.models.results as results import atr.models.safe as safe import atr.models.sql as sql +import atr.paths as paths +import atr.post as post import atr.shared as shared +import atr.shared.draft as draft +import atr.storage as storage +import atr.template as template +import atr.util as util import atr.web as web +if TYPE_CHECKING: + from collections.abc import Sequence + @get.typed async def selected( @@ -49,4 +65,275 @@ async def selected( ).demand(base.ASFQuartException("Release does not exist", errorcode=404)) if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT: return await mapping.release_as_redirect(session, release) - return await shared.web.check(session, release) + + base_path = paths.release_directory(release) + + # TODO: This takes 180ms for providers + # We could cache it + all_paths = [path async for path in util.paths_recursive(base_path)] + all_paths.sort() + + async with storage.read(session) as read: + ragp = read.as_general_public() + info = await ragp.releases.path_info(release, all_paths) + + user_ssh_keys: Sequence[sql.SSHKey] = [] + asf_id: str | None = None + server_domain: str | None = None + server_host: str | None = None + + asf_id = session.uid + server_domain = session.app_host.split(":", 1)[0] + server_host = session.app_host + async with db.session() as data: + user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all() + + async with db.session() as data: + quarantined_pending = await data.quarantined(release_key=release.key, status=sql.QuarantineStatus.PENDING).all() + quarantined_failed = await data.quarantined(release_key=release.key, status=sql.QuarantineStatus.FAILED).all() + + clear_quarantine_forms: dict[int, htm.Element] = {} + for q in quarantined_failed: + if q.id is None: + continue + clear_quarantine_forms[q.id] = form.render( + model_cls=draft.ClearQuarantineForm, + action=util.as_url( + post.draft.quarantine_clear, + project_key=release.safe_project_key, + version_key=release.safe_version_key, + ), + form_classes=".d-inline-block.m-0", + submit_classes="btn-sm btn-outline-secondary", + submit_label="Dismiss", + empty=True, + defaults={"quarantined_id": str(q.id)}, + ) + + # Get the number of ongoing tasks for the current revision + ongoing_tasks_count = 0 + match await interaction.latest_info(release.safe_project_key, release.safe_version_key): + case (revision_number, revision_editor, revision_timestamp): + ongoing_tasks_count = await interaction.tasks_ongoing( + release.safe_project_key, + release.safe_version_key, + revision_number, + ) + case None: + revision_number = None + revision_editor = None + revision_timestamp = None + + delete_form = form.render( + model_cls=form.Empty, + action=util.as_url(selected, project_key=release.safe_project_key, version_key=release.safe_version_key), + submit_label="Delete this draft", + submit_classes="btn btn-danger", + empty=True, + confirm="Are you sure you want to delete this draft? This cannot be undone.", + ) + + delete_file_forms: dict[str, htm.Element] = {} + for path in all_paths: + delete_file_forms[str(path)] = form.render( + model_cls=draft.DeleteFileForm, + action=util.as_url( + post.draft.delete_file, project_key=release.safe_project_key, version_key=release.safe_version_key + ), + form_classes=".d-inline-block.m-0", + submit_classes="btn-sm btn-outline-danger", + submit_label="Delete", + empty=True, + defaults={"file_path": str(path)}, + # TODO: Add a static check for the confirm syntax + confirm=( + "Are you sure you want to delete this file? " + "This will also delete any associated metadata files. " + "This cannot be undone." + ), + ) + + recheck_form = form.render( + model_cls=form.Empty, + action=util.as_url( + post.draft.recheck, project_key=release.safe_project_key, version_key=release.safe_version_key + ), + submit_label="Recheck with fresh cache", + submit_classes="btn btn-primary", + ) + cache_reset_form = form.render( + model_cls=form.Empty, + action=util.as_url( + post.draft.cache_reset, project_key=release.safe_project_key, version_key=release.safe_version_key + ), + submit_label="Recheck with global cache", + submit_classes="btn btn-primary", + submit_disabled=release.check_cache_key is None, + ) + + has_files = await util.has_files(release) + + blocker_errors = False + if revision_number is not None: + blocker_errors = await interaction.has_blocker_checks(release, revision_number) + + checks_summary_html = shared.web.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/move-files.js"), + **{"data-csrf-token": csrf_token}, + )[""], + ] + + return await template.render( + "check-selected.html", + project_key=release.project.key, + version_key=release.version, + release=release, + paths=all_paths, + info=info, + revision_editor=revision_editor, + revision_time=revision_timestamp, + revision_number=revision_number, + ongoing_tasks_count=ongoing_tasks_count, + quarantined_pending=quarantined_pending, + quarantined_failed=quarantined_failed, + clear_quarantine_forms=clear_quarantine_forms, + delete_form=delete_form, + delete_file_forms=delete_file_forms, + asf_id=asf_id, + server_domain=server_domain, + server_host=server_host, + user_ssh_keys=user_ssh_keys, + format_datetime=util.format_datetime, + models=sql, + recheck_form=recheck_form, + cache_reset_form=cache_reset_form, + csrf_input=str(form.csrf_input()), + has_files=has_files, + blocker_errors=blocker_errors, + checks_summary_html=checks_summary_html, + move_file_html=move_file_html, + scripts=str(scripts), + ) + + +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"], + htm.span(".text-muted.small")[" (enter a new directory name to create it)"], + ] + ] + 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 + # We should probably rethink how to send data through tasks + + if (not vote_task) or (not vote_task.result): + return ["No vote task result found."] + + vote_task_result = vote_task.result + if not isinstance(vote_task_result, results.VoteInitiate): + return ["Vote task result is not a results.VoteInitiate instance."] + + return vote_task_result.mail_send_warnings diff --git a/atr/get/finish.py b/atr/get/finish.py index 70b2c07c..831b7d42 100644 --- a/atr/get/finish.py +++ b/atr/get/finish.py @@ -23,10 +23,8 @@ from typing import Literal 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 @@ -71,9 +69,7 @@ async def selected( """ await session.check_access(project_key) try: - (release, source_files_rel, target_dirs, deletable_dirs, rc_analysis, tasks) = await _get_page_data( - project_key, version_key - ) + (release, deletable_dirs, rc_analysis, tasks) = await _get_page_data(project_key, version_key) except ValueError: async with db.session() as data: release_fallback = await data.release( @@ -105,8 +101,6 @@ async def selected( return await _render_page( release=release, - source_files_rel=source_files_rel, - target_dirs=target_dirs, deletable_dirs=deletable_dirs, rc_analysis=rc_analysis, distribution_tasks=tasks, @@ -156,9 +150,7 @@ async def _deletable_choices( async def _get_page_data( project_key: safe.ProjectKey, version_key: safe.VersionKey -) -> tuple[ - sql.Release, list[pathlib.Path], set[pathlib.Path], list[tuple[str, str]], RCTagAnalysisResult, Sequence[sql.Task] -]: +) -> tuple[sql.Release, list[tuple[str, str]], RCTagAnalysisResult, Sequence[sql.Task]]: """Get all the data needed to render the finish page.""" async with db.session() as data: via = sql.validate_instrumented_attribute @@ -189,11 +181,11 @@ async def _get_page_data( raise ValueError("Release is not in preview phase") latest_revision_dir = paths.release_directory(release) - source_files_rel, target_dirs = await _sources_and_targets(latest_revision_dir) + _, 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, tasks + return release, deletable_dirs, rc_analysis_result, tasks def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> htm.Element: @@ -293,79 +285,8 @@ def _render_distribution_tasks(release: sql.Release, tasks: Sequence[sql.Task]) return block.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, distribution_tasks: Sequence[sql.Task], @@ -405,9 +326,6 @@ async def _render_page( page.append(_render_dist_warning()) page.append(_render_distribution_buttons(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)) @@ -442,24 +360,6 @@ async def _render_page( """ page.style[markupsafe.Markup(page_styles)] - # JavaScript data - # TODO: Add htm.script - csrf_token = utils.generate_csrf() - # Should be already validated, but check again - 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)] - page.append( - htpy.script(id="file-data", type="application/json")[util.json_for_script_element(safe_source_files_rel)] - ) - page.append(htpy.script(id="dir-data", type="application/json")[util.json_for_script_element(safe_target_dirs)]) - page.append( - htpy.script( - id="main-script-data", - src=util.static_url("js/ts/finish-selected-move.js"), - **{"data-csrf-token": csrf_token}, - )[""] - ) - content = page.collect() return await template.blank( diff --git a/atr/post/compose.py b/atr/post/compose.py index 640a5e6e..5c4aaca4 100644 --- a/atr/post/compose.py +++ b/atr/post/compose.py @@ -15,7 +15,6 @@ # specific language governing permissions and limitations # under the License. -from collections.abc import Awaitable, Callable from typing import Literal import quart @@ -34,7 +33,7 @@ async def selected( _compose: Literal["compose"], project_key: safe.ProjectKey, version_key: safe.VersionKey, - move_form: shared.finish.MoveFileForm, + move_form: shared.compose.MoveFileForm, ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse: """ URL: /compose/<project_key>/<version_key> @@ -46,11 +45,11 @@ async def selected( async def _move_file_to_revision( - move_form: shared.finish.MoveFileForm, + move_form: shared.compose.MoveFileForm, session: web.Committer, project_key: safe.ProjectKey, version_key: safe.VersionKey, - respond: Callable[[int, str], Awaitable[tuple[web.QuartResponse, int] | web.WerkzeugResponse]], + respond: shared.compose.Respond, ) -> tuple[web.QuartResponse, int] | web.WerkzeugResponse: source_files_rel = move_form.source_files target_dir_rel = move_form.target_directory @@ -91,7 +90,7 @@ async def _move_file_to_revision( 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]]: +) -> shared.compose.Respond: """Create a response helper function for the compose route.""" import atr.get as get diff --git a/atr/post/finish.py b/atr/post/finish.py index afabcc1f..f370486e 100644 --- a/atr/post/finish.py +++ b/atr/post/finish.py @@ -45,8 +45,6 @@ async def selected( match finish_form: case shared.finish.DeleteEmptyDirectoryForm() as delete_form: return await _delete_empty_directory(delete_form, session, project_key, version_key, respond) - case shared.finish.MoveFileForm() as move_form: - return await _move_file_to_revision(move_form, session, project_key, version_key, respond) case shared.finish.RemoveRCTagsForm(): return await _remove_rc_tags(session, project_key, version_key, respond) @@ -74,50 +72,6 @@ async def _delete_empty_directory( 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_key: safe.ProjectKey, - version_key: safe.VersionKey, - respond: shared.finish.Respond, -) -> 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}") - - async def _remove_rc_tags( session: web.Committer, project_key: safe.ProjectKey, diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py index 4090d591..2e639a6b 100644 --- a/atr/shared/__init__.py +++ b/atr/shared/__init__.py @@ -18,6 +18,7 @@ from typing import Final import atr.shared.announce as announce +import atr.shared.compose as compose import atr.shared.distribution as distribution import atr.shared.draft as draft import atr.shared.finish as finish @@ -66,6 +67,7 @@ algorithms: Final[dict[int, str]] = { __all__ = [ "algorithms", "announce", + "compose", "distribution", "draft", "finish", diff --git a/atr/shared/finish.py b/atr/shared/compose.py similarity index 77% copy from atr/shared/finish.py copy to atr/shared/compose.py index 1a7f835f..eb0daf72 100644 --- a/atr/shared/finish.py +++ b/atr/shared/compose.py @@ -16,7 +16,7 @@ # under the License. from collections.abc import Awaitable, Callable -from typing import Annotated, Literal +from typing import Literal import pydantic @@ -26,14 +26,7 @@ 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(form.Form): - variant: DELETE_DIR = form.value(DELETE_DIR) - directory_to_delete: safe.RelPath = form.label("Directory to delete", widget=form.Widget.SELECT) class MoveFileForm(form.Form): @@ -52,13 +45,3 @@ class MoveFileForm(form.Form): if source.parent == target_dir_path: raise ValueError(f"Target directory cannot be the same as the source directory for {source.name}.") return self - - -class RemoveRCTagsForm(form.Empty): - variant: REMOVE_RC_TAGS = form.value(REMOVE_RC_TAGS) - - -type FinishForm = Annotated[ - DeleteEmptyDirectoryForm | MoveFileForm | RemoveRCTagsForm, - form.DISCRIMINATOR, -] diff --git a/atr/shared/finish.py b/atr/shared/finish.py index 1a7f835f..7b511368 100644 --- a/atr/shared/finish.py +++ b/atr/shared/finish.py @@ -18,8 +18,6 @@ from collections.abc import Awaitable, Callable from typing import Annotated, Literal -import pydantic - import atr.form as form import atr.models.safe as safe import atr.web as web @@ -27,7 +25,6 @@ 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"] @@ -36,29 +33,11 @@ class DeleteEmptyDirectoryForm(form.Form): directory_to_delete: safe.RelPath = form.label("Directory to delete", widget=form.Widget.SELECT) -class MoveFileForm(form.Form): - variant: MOVE_FILE = form.value(MOVE_FILE) - source_files: form.RelPathList = form.label("Files to move") - target_directory: safe.RelPath = form.label("Target directory") - - @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.") - - target_dir_path = self.target_directory.as_path() - for source_path in self.source_files: - source = source_path.as_path() - if source.parent == target_dir_path: - raise ValueError(f"Target directory cannot be the same as the source directory for {source.name}.") - return self - - class RemoveRCTagsForm(form.Empty): variant: REMOVE_RC_TAGS = form.value(REMOVE_RC_TAGS) type FinishForm = Annotated[ - DeleteEmptyDirectoryForm | MoveFileForm | RemoveRCTagsForm, + DeleteEmptyDirectoryForm | RemoveRCTagsForm, form.DISCRIMINATOR, ] diff --git a/atr/shared/web.py b/atr/shared/web.py index 1e87d1d2..49c11f7e 100644 --- a/atr/shared/web.py +++ b/atr/shared/web.py @@ -14,219 +14,13 @@ # 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 -import atr.form as form -import atr.get as get import atr.htm as htm -import atr.models.results as results import atr.models.safe as safe -import atr.models.sql as sql -import atr.paths as paths -import atr.post as post -import atr.shared.draft as draft -import atr.storage as storage import atr.storage.types as types -import atr.template as template import atr.util as util -import atr.web as web - -if TYPE_CHECKING: - from collections.abc import Sequence - - -async def check( - session: web.Committer | None, - release: sql.Release, - task_mid: str | None = None, - vote_form: htm.Element | None = None, - resolve_form: htm.Element | None = None, - archive_url: str | None = None, - vote_task: sql.Task | None = None, - can_vote: bool = False, - can_resolve: bool = False, -) -> web.WerkzeugResponse | str: - base_path = paths.release_directory(release) - - # TODO: This takes 180ms for providers - # We could cache it - all_paths = [path async for path in util.paths_recursive(base_path)] - all_paths.sort() - - async with storage.read(session) as read: - ragp = read.as_general_public() - info = await ragp.releases.path_info(release, all_paths) - - user_ssh_keys: Sequence[sql.SSHKey] = [] - asf_id: str | None = None - server_domain: str | None = None - server_host: str | None = None - - if session is not None: - asf_id = session.uid - server_domain = session.app_host.split(":", 1)[0] - server_host = session.app_host - async with db.session() as data: - user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all() - - async with db.session() as data: - quarantined_pending = await data.quarantined(release_key=release.key, status=sql.QuarantineStatus.PENDING).all() - quarantined_failed = await data.quarantined(release_key=release.key, status=sql.QuarantineStatus.FAILED).all() - - clear_quarantine_forms: dict[int, htm.Element] = {} - if session is not None: - for q in quarantined_failed: - if q.id is None: - continue - clear_quarantine_forms[q.id] = form.render( - model_cls=draft.ClearQuarantineForm, - action=util.as_url( - post.draft.quarantine_clear, - project_key=release.safe_project_key, - version_key=release.safe_version_key, - ), - form_classes=".d-inline-block.m-0", - submit_classes="btn-sm btn-outline-secondary", - submit_label="Dismiss", - empty=True, - defaults={"quarantined_id": str(q.id)}, - ) - - # Get the number of ongoing tasks for the current revision - ongoing_tasks_count = 0 - match await interaction.latest_info(release.safe_project_key, release.safe_version_key): - case (revision_number, revision_editor, revision_timestamp): - ongoing_tasks_count = await interaction.tasks_ongoing( - release.safe_project_key, - release.safe_version_key, - revision_number, - ) - case None: - revision_number = None - revision_editor = None - revision_timestamp = None - - delete_form = form.render( - model_cls=form.Empty, - action=util.as_url( - get.compose.selected, project_key=release.safe_project_key, version_key=release.safe_version_key - ), - submit_label="Delete this draft", - submit_classes="btn btn-danger", - empty=True, - confirm="Are you sure you want to delete this draft? This cannot be undone.", - ) - - delete_file_forms: dict[str, htm.Element] = {} - for path in all_paths: - delete_file_forms[str(path)] = form.render( - model_cls=draft.DeleteFileForm, - action=util.as_url( - post.draft.delete_file, project_key=release.safe_project_key, version_key=release.safe_version_key - ), - form_classes=".d-inline-block.m-0", - submit_classes="btn-sm btn-outline-danger", - submit_label="Delete", - empty=True, - defaults={"file_path": str(path)}, - # TODO: Add a static check for the confirm syntax - confirm=( - "Are you sure you want to delete this file? " - "This will also delete any associated metadata files. " - "This cannot be undone." - ), - ) - - recheck_form = form.render( - model_cls=form.Empty, - action=util.as_url( - post.draft.recheck, project_key=release.safe_project_key, version_key=release.safe_version_key - ), - submit_label="Recheck with fresh cache", - submit_classes="btn btn-primary", - ) - cache_reset_form = form.render( - model_cls=form.Empty, - action=util.as_url( - post.draft.cache_reset, project_key=release.safe_project_key, version_key=release.safe_version_key - ), - submit_label="Recheck with global cache", - submit_classes="btn btn-primary", - submit_disabled=release.check_cache_key is None, - ) - - vote_task_warnings = _warnings_from_vote_result(vote_task) - has_files = await util.has_files(release) - - blocker_errors = False - if revision_number is not None: - 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", - project_key=release.project.key, - version_key=release.version, - release=release, - paths=all_paths, - info=info, - revision_editor=revision_editor, - revision_time=revision_timestamp, - revision_number=revision_number, - ongoing_tasks_count=ongoing_tasks_count, - quarantined_pending=quarantined_pending, - quarantined_failed=quarantined_failed, - clear_quarantine_forms=clear_quarantine_forms, - delete_form=delete_form, - delete_file_forms=delete_file_forms, - asf_id=asf_id, - server_domain=server_domain, - server_host=server_host, - user_ssh_keys=user_ssh_keys, - format_datetime=util.format_datetime, - models=sql, - task_mid=task_mid, - vote_form=vote_form, - vote_task=vote_task, - archive_url=archive_url, - vote_task_warnings=vote_task_warnings, - recheck_form=recheck_form, - cache_reset_form=cache_reset_form, - csrf_input=str(form.csrf_input()), - resolve_form=resolve_form, - has_files=has_files, - blocker_errors=blocker_errors, - can_vote=can_vote, - can_resolve=can_resolve, - checks_summary_html=checks_summary_html, - move_file_html=move_file_html, - scripts=str(scripts), - ) def render_checks_summary( @@ -284,113 +78,3 @@ def render_checks_summary( 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"], - htm.span(".text-muted.small")[" (enter a new directory name to create it)"], - ] - ] - 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 - # We should probably rethink how to send data through tasks - - if (not vote_task) or (not vote_task.result): - return ["No vote task result found."] - - vote_task_result = vote_task.result - if not isinstance(vote_task_result, results.VoteInitiate): - return ["Vote task result is not a results.VoteInitiate instance."] - - return vote_task_result.mail_send_warnings diff --git a/atr/static/js/ts/create-a-jwt.js b/atr/static/js/ts/create-a-jwt.js index caf57e24..8f9b6bfb 100644 --- a/atr/static/js/ts/create-a-jwt.js +++ b/atr/static/js/ts/create-a-jwt.js @@ -43,6 +43,7 @@ document.addEventListener("DOMContentLoaded", () => { timeoutObj = setTimeout(() => { output.textContent = ""; outputContainer.classList.add("d-none"); + clearInterval(intervalObj); }, 60000); intervalObj = setInterval(() => { time = time - 1; diff --git a/atr/static/js/ts/create-a-jwt.js.map b/atr/static/js/ts/create-a-jwt.js.map index 2ebe6f4b..72e5e216 100644 --- a/atr/static/js/ts/create-a-jwt.js.map +++ b/atr/static/js/ts/create-a-jwt.js.map @@ -1 +1 @@ -{"version":3,"file":"create-a-jwt.js","sourceRoot":"","sources":["../../ts/create-a-jwt.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,GAAS,EAAE;IACvD,MAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAA2B,CAAC;IACjF,MAAM,eAAe,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAA;IAChE,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;IACrD,MAAM,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,CAAC;IAC5D,IAAI,UAAkB,EAAE,WAAmB,CAAC;IAE5C,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,I [...] \ No newline at end of file +{"version":3,"file":"create-a-jwt.js","sourceRoot":"","sources":["../../ts/create-a-jwt.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,QAAQ,CAAC,gBAAgB,CAAC,kBAAkB,EAAE,GAAS,EAAE;IACvD,MAAM,IAAI,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAA2B,CAAC;IACjF,MAAM,eAAe,GAAG,QAAQ,CAAC,cAAc,CAAC,eAAe,CAAC,CAAA;IAChE,MAAM,MAAM,GAAG,QAAQ,CAAC,cAAc,CAAC,YAAY,CAAC,CAAC;IACrD,MAAM,SAAS,GAAG,QAAQ,CAAC,cAAc,CAAC,gBAAgB,CAAC,CAAC;IAC5D,IAAI,UAAkB,EAAE,WAAmB,CAAC;IAE5C,IAAI,CAAC,IAAI,IAAI,CAAC,MAAM,I [...] \ No newline at end of file diff --git a/atr/static/js/ts/finish-selected-move.js.map b/atr/static/js/ts/finish-selected-move.js.map deleted file mode 100644 index efa6a0a6..00000000 --- a/atr/static/js/ts/finish-selected-move.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"finish-selected-move.js","sourceRoot":"","sources":["../../ts/finish-selected-move.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,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, [...] \ No newline at end of file diff --git a/atr/static/js/ts/finish-selected-move.js b/atr/static/js/ts/move-files.js similarity index 99% rename from atr/static/js/ts/finish-selected-move.js rename to atr/static/js/ts/move-files.js index 56aebc7f..13d5c5d9 100644 --- a/atr/static/js/ts/finish-selected-move.js +++ b/atr/static/js/ts/move-files.js @@ -486,4 +486,4 @@ document.addEventListener("DOMContentLoaded", () => { }); renderAllLists(); }); -//# sourceMappingURL=finish-selected-move.js.map \ No newline at end of file +//# sourceMappingURL=move-files.js.map \ No newline at end of file diff --git a/atr/static/js/ts/move-files.js.map b/atr/static/js/ts/move-files.js.map new file mode 100644 index 00000000..1d8810d5 --- /dev/null +++ b/atr/static/js/ts/move-files.js.map @@ -0,0 +1 @@ +{"version":3,"file":"move-files.js","sourceRoot":"","sources":["../../ts/move-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;EAiBE;AAEF,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 [...] \ No newline at end of file diff --git a/atr/static/ts/finish-selected-move.ts b/atr/static/ts/move-files.ts similarity index 100% rename from atr/static/ts/finish-selected-move.ts rename to atr/static/ts/move-files.ts diff --git a/tests/e2e/announce/test_get.py b/tests/e2e/announce/test_get.py index 72872f86..56cf8000 100644 --- a/tests/e2e/announce/test_get.py +++ b/tests/e2e/announce/test_get.py @@ -19,19 +19,6 @@ import e2e.announce.helpers as helpers # type: ignore[reportMissingImports] from playwright.sync_api import Page, expect -def test_finish_move_form_populates_from_json(page_finish: Page) -> None: - """The finish move form should populate rows from script JSON data.""" - file_option = page_finish.locator("#file-list-table-body input[type='checkbox'][data-item-path]").first - dir_option = page_finish.locator("#dir-list-table-body input[type='radio'][name='target-directory-radio']").first - expect(file_option).to_be_visible() - expect(dir_option).to_be_visible() - - toggle_button = page_finish.locator("#select-files-toggle-button") - expect(toggle_button).to_have_text("Select these files") - toggle_button.click() - expect(toggle_button).to_have_text("Unselect all") - - def test_path_adds_leading_slash(page_announce: Page) -> None: """Paths without a leading '/' should have one added.""" help_text = helpers.fill_path_suffix(page_announce, "apple/banana") diff --git a/tests/e2e/compose/conftest.py b/tests/e2e/compose/conftest.py index 3ab325f8..4962ca42 100644 --- a/tests/e2e/compose/conftest.py +++ b/tests/e2e/compose/conftest.py @@ -75,6 +75,6 @@ def page_compose(compose_context: BrowserContext) -> Generator[Page]: """Navigate to the compose page with a fresh page for each test.""" page = compose_context.new_page() helpers.visit(page, COMPOSE_URL) - page.get_by_role("cell", name=FILE_NAME, exact=True).wait_for(timeout=60000) + page.locator("#files-table-container").get_by_role("cell", name=FILE_NAME, exact=True).wait_for(timeout=60000) yield page page.close() diff --git a/tests/e2e/compose/test_get.py b/tests/e2e/compose/test_get.py index 2c0fcda9..f25ce7e2 100644 --- a/tests/e2e/compose/test_get.py +++ b/tests/e2e/compose/test_get.py @@ -111,3 +111,16 @@ def test_start_vote_button_has_title(page_compose: Page) -> None: """The start vote button should have a descriptive title.""" vote_button = page_compose.locator("#start-vote-button") expect(vote_button).to_have_attribute("title", "Start a vote on this draft") + + +def test_move_form_populates_from_json(page_compose: Page) -> None: + """The move form should populate rows from script JSON data.""" + file_option = page_compose.locator("#file-list-table-body input[type='checkbox'][data-item-path]").first + dir_option = page_compose.locator("#dir-list-table-body input[type='radio'][name='target-directory-radio']").first + expect(file_option).to_be_visible() + expect(dir_option).to_be_visible() + + toggle_button = page_compose.locator("#select-files-toggle-button") + expect(toggle_button).to_have_text("Select these files") + toggle_button.click() + expect(toggle_button).to_have_text("Unselect all") diff --git a/tests/e2e/sbom/test_post.py b/tests/e2e/sbom/test_post.py index dac26578..ebceedfe 100644 --- a/tests/e2e/sbom/test_post.py +++ b/tests/e2e/sbom/test_post.py @@ -22,7 +22,9 @@ from playwright.sync_api import Page, expect def test_sbom_generate(page_release_with_file: Page) -> None: # Make sure that the test file exists - file_cell = page_release_with_file.get_by_role("cell", name=sbom_helpers.FILE_NAME, exact=True) + file_cell = page_release_with_file.locator("#files-table-container").get_by_role( + "cell", name=sbom_helpers.FILE_NAME, exact=True + ) expect(file_cell).to_be_visible() # Generate an SBOM for the file @@ -38,5 +40,7 @@ def test_sbom_generate(page_release_with_file: Page) -> None: page_release_with_file.wait_for_selector("#ongoing-tasks-banner", state="hidden") page_release_with_file.reload() - sbom_cell = page_release_with_file.get_by_role("cell", name=f"{sbom_helpers.FILE_NAME}.cdx.json") + sbom_cell = page_release_with_file.locator("#files-table-container").get_by_role( + "cell", name=f"{sbom_helpers.FILE_NAME}.cdx.json" + ) expect(sbom_cell).to_be_visible() --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
