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-release.git
The following commit(s) were added to refs/heads/main by this push:
new 15f847d Add a form to move release preview files, and improve path
construction
15f847d is described below
commit 15f847d0b8ad7f4ff7cb45a6201380414e0fca84
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Apr 23 20:24:51 2025 +0100
Add a form to move release preview files, and improve path construction
---
atr/blueprints/admin/admin.py | 2 +-
atr/routes/candidate.py | 29 +++----
atr/routes/draft.py | 29 +++----
atr/routes/preview.py | 126 +++++++++++++++++++++++++++---
atr/routes/release.py | 2 +-
atr/tasks/__init__.py | 1 +
atr/templates/preview-refine-release.html | 47 +++++++++--
atr/util.py | 7 ++
playwright/test.py | 15 +++-
9 files changed, 200 insertions(+), 58 deletions(-)
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 8773e2b..3347056 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -395,7 +395,7 @@ async def _delete_release_data(release_name: str) -> None:
release = await data.release(name=release_name).demand(
base.ASFQuartException(f"Release '{release_name}' not found.", 404)
)
- release_dir = util.release_directory(release)
+ release_dir = util.release_directory_base(release)
# Delete from the database
_LOGGER.info("Deleting database records for release: %s", release_name)
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 4667575..e5168c8 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -147,14 +147,10 @@ async def view_path(
) -> response.Response | str:
"""View the content of a specific file in the release candidate."""
await session.check_access(project_name)
-
- async with db.session() as data:
- release = await data.release(name=models.release_name(project_name,
version_name), _project=True).demand(
- base.ASFQuartException("Release does not exist", errorcode=404)
- )
+ release = await session.release(project_name, version_name)
_max_view_size = 1 * 1024 * 1024
- full_path = util.get_release_candidate_dir() / project_name / version_name
/ file_path
+ full_path = util.release_directory(release) / file_path
content_listing = await util.archive_listing(full_path)
content, is_text, is_truncated, error_message = await
util.read_file_for_viewer(full_path, _max_view_size)
return await quart.render_template(
@@ -245,7 +241,7 @@ async def _resolve_post(session: routes.CommitterSession)
-> response.Response:
# Extract project name
try:
- project_name = candidate_name.rsplit("-", 1)[0]
+ project_name, version_name = candidate_name.rsplit("-", 1)
except ValueError:
return await session.redirect(resolve, error="Invalid candidate name
format")
@@ -255,13 +251,13 @@ async def _resolve_post(session: routes.CommitterSession)
-> response.Response:
# Update release status in the database
async with db.session() as data:
async with data.begin():
- release = await data.release(name=candidate_name,
_project=True).demand(
- routes.FlashError("Release candidate not found")
+ release = await session.release(
+ project_name, version_name,
phase=models.ReleasePhase.RELEASE_CANDIDATE, data=data
)
- # Verify that it's in the correct phase
- if release.phase != models.ReleasePhase.RELEASE_CANDIDATE:
- return await session.redirect(resolve, error="This release is
not in the voting phase")
+ # Get the source directory for the release candidate
+ # We need to do it here because we're updating the release status
in the database
+ source = str(util.release_directory(release))
# Update the release phase based on vote result
if vote_result == "passed":
@@ -272,15 +268,16 @@ async def _resolve_post(session: routes.CommitterSession)
-> response.Response:
release.phase = models.ReleasePhase.RELEASE_CANDIDATE_DRAFT
success_message = "Vote marked as failed"
- await _resolve_post_files(project_name, release, vote_result, session.uid)
+ await _resolve_post_files(project_name, release, source, vote_result,
session.uid)
return await session.redirect(
- preview.announce_release, success=success_message,
project_name=project_name, version_name=release.version
+ preview.refine_release, success=success_message,
project_name=project_name, version_name=release.version
)
-async def _resolve_post_files(project_name: str, release: models.Release,
vote_result: str, asf_uid: str) -> None:
+async def _resolve_post_files(
+ project_name: str, release: models.Release, source: str, vote_result: str,
asf_uid: str
+) -> None:
# TODO: Obtain a lock for this
- source = str(util.get_release_candidate_dir() / project_name /
release.version)
if vote_result != "passed":
# The vote failed, so move the release candidate to the release draft
directory
async with revision.create_and_manage(project_name, release.version,
asf_uid) as (
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index d27da8c..49fb7db 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -250,7 +250,7 @@ async def compose(session: routes.CommitterSession,
project_name: str, version_n
await session.check_access(project_name)
release = await session.release(project_name, version_name)
- base_path = util.get_release_candidate_draft_dir() / project_name /
version_name / release.unwrap_revision
+ base_path = util.release_directory(release)
paths = await util.paths_recursive(base_path)
path_templates = {}
path_substitutions = {}
@@ -414,6 +414,7 @@ async def delete(session: routes.CommitterSession) ->
response.Response:
return await session.redirect(drafts, error=f"Error deleting
candidate draft: {e!s}")
# Delete the files on disk, including all revisions
+ # We can't use util.release_directory_base here because we don't have the
release object
draft_dir = util.get_release_candidate_draft_dir() / project_name / version
if await aiofiles.os.path.exists(draft_dir):
# Believe this to be another bug in mypy Protocol handling
@@ -512,7 +513,7 @@ async def evaluate(session: routes.CommitterSession,
project_name: str, version_
await session.check_access(project_name)
release = await session.release(project_name, version_name,
with_committee=True, with_project=False)
- base_path = util.get_release_candidate_draft_dir() / project_name /
version_name / release.unwrap_revision
+ base_path = util.release_directory(release)
paths = await util.paths_recursive(base_path)
# paths_set = set(paths)
path_templates = {}
@@ -550,9 +551,7 @@ async def evaluate(session: routes.CommitterSession,
project_name: str, version_
path_metadata.add(path)
# Get modified time
- full_path = str(
- util.get_release_candidate_draft_dir() / project_name /
version_name / release.unwrap_revision / path
- )
+ full_path = str(util.release_directory(release) / path)
path_modified[path] = int(await aiofiles.os.path.getmtime(full_path))
# Get successes, warnings, and errors
@@ -607,7 +606,7 @@ async def evaluate_path(session: routes.CommitterSession,
project_name: str, ver
release = await session.release(project_name, version_name)
# TODO: When we do more than one thing in a dir, we should use the
revision directory directly
- abs_path = util.get_release_candidate_draft_dir() / project_name /
version_name / release.unwrap_revision / rel_path
+ abs_path = util.release_directory(release) / rel_path
# Check that the file exists
if not await aiofiles.os.path.exists(abs_path):
@@ -726,12 +725,11 @@ async def revision_set(session: routes.CommitterSession,
project_name: str, vers
async with db.session() as data:
try:
release = await session.release(project_name, version_name,
data=data)
- release_dir = util.get_release_candidate_draft_dir() /
project_name / version_name
except base.ASFQuartException:
release = await session.release(
project_name, version_name,
phase=models.ReleasePhase.RELEASE_PREVIEW, data=data
)
- release_dir = util.get_release_preview_dir() / project_name /
version_name
+ release_dir = util.release_directory_base(release)
# Check that the target revision directory exists
target_revision_dir = release_dir / revision_name
@@ -767,12 +765,11 @@ async def revisions(session: routes.CommitterSession,
project_name: str, version
try:
release = await session.release(project_name, version_name)
- release_dir = util.get_release_candidate_draft_dir() / project_name /
version_name
phase_key = "draft"
except base.ASFQuartException:
release = await session.release(project_name, version_name,
phase=models.ReleasePhase.RELEASE_PREVIEW)
- release_dir = util.get_release_preview_dir() / project_name /
version_name
phase_key = "preview"
+ release_dir = util.release_directory_base(release)
revision_dirs: list[str] = []
with contextlib.suppress(FileNotFoundError):
@@ -1009,9 +1006,7 @@ async def tools(session: routes.CommitterSession,
project_name: str, version_nam
await session.check_access(project_name)
release = await session.release(project_name, version_name)
- full_path = str(
- util.get_release_candidate_draft_dir() / project_name / version_name /
release.unwrap_revision / file_path
- )
+ full_path = str(util.release_directory(release) / file_path)
# Check that the file exists
if not await aiofiles.os.path.exists(full_path):
@@ -1074,9 +1069,7 @@ async def view_path(
# Limit to 256 KiB
_max_view_size = 256 * 1024
- full_path = (
- util.get_release_candidate_draft_dir() / project_name / version_name /
release.unwrap_revision / file_path
- )
+ full_path = util.release_directory(release) / file_path
# Attempt to get an archive listing
# This will be None if the file is not an archive
@@ -1327,7 +1320,7 @@ async def _promote(
if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
return "This release is not in the candidate draft phase"
- base_dir = util.get_release_candidate_draft_dir() / project_name /
version_name
+ base_dir = util.release_directory_base(release)
# Use the directory of the specified revision
# TODO: This ensures that we promote the correct revision, but does not
stop conflicts
# We need to obtain a lock when promoting
@@ -1345,7 +1338,7 @@ async def _promote(
release.stage = models.ReleaseStage.RELEASE_CANDIDATE
release.phase = models.ReleasePhase.RELEASE_CANDIDATE
release.revision = None
- target_dir = util.get_release_candidate_dir() / project_name / version_name
+ target_dir = util.release_directory(release)
if await aiofiles.os.path.exists(target_dir):
return f"Target directory {target_dir.name} already exists"
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 8e428c9..f970e13 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -18,6 +18,8 @@
"""preview.py"""
import logging
+import pathlib
+from typing import Final
import aiofiles.os
import aioshutil
@@ -28,6 +30,7 @@ import wtforms
import atr.db as db
import atr.db.models as models
+import atr.revision as revision
import atr.routes as routes
import atr.routes.release as routes_release
import atr.util as util
@@ -35,6 +38,26 @@ import atr.util as util
if asfquart.APP is ...:
raise RuntimeError("APP is not set")
+_LOGGER: Final = logging.getLogger(__name__)
+
+
+class MoveFileForm(util.QuartFormTyped):
+ """Form for moving a file within a preview revision."""
+
+ source_file = wtforms.SelectField("File to move", choices=[],
validators=[wtforms.validators.InputRequired()])
+ target_directory = wtforms.SelectField(
+ "Target directory", choices=[],
validators=[wtforms.validators.InputRequired()]
+ )
+ submit = wtforms.SubmitField("Move file")
+
+ def validate_target_directory(self, field: wtforms.Field) -> None:
+ # This validation runs only if both fields have data
+ if self.source_file.data and field.data:
+ source_path = pathlib.Path(self.source_file.data)
+ target_dir = pathlib.Path(field.data)
+ if source_path.parent == target_dir:
+ raise wtforms.validators.ValidationError("Target directory
cannot be the same as the source directory.")
+
class AnnounceForm(util.QuartFormTyped):
"""Form for announcing a release preview."""
@@ -90,17 +113,13 @@ async def announce(session: routes.CommitterSession) ->
str | response.Response:
if (quart.request.method == "POST") and (await
announce_form.validate_on_submit()):
try:
- preview_name, project_name, version_name, preview_revision =
_announce_form_validate(announce_form)
+ _preview_name, project_name, version_name, _preview_revision =
_announce_form_validate(announce_form)
except ValueError as e:
return await session.redirect(announce, error=str(e))
# Check that the user has access to the project
async with db.session() as data:
- project = await data.project(name=project_name).get()
- if not project:
- return await session.redirect(announce, error="Project not
found")
- if not any((p.id == project.id) for p in (await
session.user_projects)):
- return await session.redirect(announce, error="You do not have
access to this project")
+ await session.check_access(project_name)
try:
# Get the release
@@ -112,15 +131,18 @@ async def announce(session: routes.CommitterSession) ->
str | response.Response:
# Impossible, but to satisfy the type checkers
return await session.redirect(announce, error="This
release does not have a revision")
- source_base = util.get_release_preview_dir() / project_name /
version_name
+ source_base = util.release_directory_base(release)
source = str(source_base / release.revision)
- target = str(util.get_release_dir() / project_name /
version_name)
- if await aiofiles.os.path.exists(target):
- return await session.redirect(announce, error="Release
already exists")
# Update the database
release.phase = models.ReleasePhase.RELEASE
release.revision = None
+
+ # This must come after updating the release object
+ target = str(util.release_directory(release))
+ if await aiofiles.os.path.exists(target):
+ return await session.redirect(announce, error="Release
already exists")
+
await data.commit()
# Move the revision directory
@@ -195,6 +217,7 @@ async def delete(session: routes.CommitterSession) ->
response.Response:
return await session.redirect(announce, error=f"Error deleting
preview: {e!s}")
# Delete the files on disk, including all revisions
+ # We can't use util.release_directory_base here because we don't have the
release object
preview_dir = util.get_release_preview_dir() / project_name / version
if await aiofiles.os.path.exists(preview_dir):
await aioshutil.rmtree(preview_dir)
@@ -221,14 +244,43 @@ async def previews(session: routes.CommitterSession) ->
str:
)
[email protected]("/refine/<project_name>/<version_name>")
[email protected]("/refine/<project_name>/<version_name>", methods=["GET",
"POST"])
async def refine_release(
session: routes.CommitterSession, project_name: str, version_name: str
) -> response.Response | str:
"""Refine a release preview."""
await session.check_access(project_name)
release = await session.release(project_name, version_name,
phase=models.ReleasePhase.RELEASE_PREVIEW)
- return await quart.render_template("preview-refine-release.html",
release=release)
+
+ current_revision_dir = util.release_directory(release)
+ file_paths_rel: list[pathlib.Path] = []
+ unique_dirs: set[pathlib.Path] = {pathlib.Path(".")}
+
+ try:
+ for path in await util.paths_recursive(current_revision_dir):
+ file_paths_rel.append(path)
+ unique_dirs.add(path.parent)
+ except FileNotFoundError:
+ await quart.flash("Preview revision directory not found.", "error")
+ return await session.redirect(previews)
+
+ form = await MoveFileForm.create_form(data=await quart.request.form if
(quart.request.method == "POST") else None)
+
+ # Populate choices dynamically for both GET and POST
+ form.source_file.choices = sorted([(str(p), str(p)) for p in
file_paths_rel])
+ form.target_directory.choices = sorted([(str(d), str(d)) for d in
unique_dirs])
+ can_move = len(unique_dirs) > 1
+
+ if (quart.request.method == "POST") and can_move:
+ match r := await _move_file(form, session, project_name, version_name):
+ case None:
+ pass
+ case response.Response():
+ return r
+
+ return await quart.render_template(
+ "preview-refine-release.html", release=release,
file_paths=sorted(file_paths_rel), form=form, can_move=can_move
+ )
@routes.committer("/preview/view/<project_name>/<version_name>")
@@ -267,7 +319,7 @@ async def view_path(
release = await session.release(project_name, version_name,
phase=models.ReleasePhase.RELEASE_PREVIEW)
_max_view_size = 1 * 1024 * 1024
- full_path = util.get_release_preview_dir() / project_name / version_name /
release.unwrap_revision / file_path
+ full_path = util.release_directory(release) / file_path
content_listing = await util.archive_listing(full_path)
content, is_text, is_truncated, error_message = await
util.read_file_for_viewer(full_path, _max_view_size)
return await quart.render_template(
@@ -325,3 +377,51 @@ async def _delete_preview(data: db.Session, preview_name:
str) -> None:
# Delete the release record
await data.delete(release)
+
+
+async def _move_file(
+ form: MoveFileForm, session: routes.CommitterSession, project_name: str,
version_name: str
+) -> response.Response | None:
+ if await form.validate_on_submit():
+ source_file_rel = pathlib.Path(form.source_file.data)
+ target_dir_rel = pathlib.Path(form.target_directory.data)
+
+ try:
+ async with revision.create_and_manage(project_name, version_name,
session.uid, preview=True) as (
+ new_revision_dir,
+ new_revision_name,
+ ):
+ source_path_in_new = new_revision_dir / source_file_rel
+ target_path_in_new = new_revision_dir / target_dir_rel /
source_file_rel.name
+
+ if await aiofiles.os.path.exists(target_path_in_new):
+ await quart.flash(
+ f"File '{source_file_rel.name}' already exists in
'{target_dir_rel}' in new revision.",
+ "error",
+ )
+ return await session.redirect(refine_release,
project_name=project_name, version_name=version_name)
+
+ _LOGGER.info(f"Moving {source_path_in_new} to
{target_path_in_new} in new revision {new_revision_name}")
+ await aiofiles.os.rename(source_path_in_new,
target_path_in_new)
+
+ await quart.flash(
+ f"File '{source_file_rel.name}' moved successfully to
'{target_dir_rel}' in new revision.", "success"
+ )
+ return await session.redirect(refine_release,
project_name=project_name, version_name=version_name)
+
+ except FileNotFoundError:
+ _LOGGER.exception("File not found during move operation in new
revision")
+ await quart.flash("Error: Source file not found during move
operation.", "error")
+ except OSError as e:
+ _LOGGER.exception("Error moving file in new revision")
+ await quart.flash(f"Error moving file: {e}", "error")
+ except Exception as e:
+ _LOGGER.exception("Unexpected error during file move")
+ await quart.flash(f"An unexpected error occurred: {e!s}", "error")
+ return await session.redirect(refine_release,
project_name=project_name, version_name=version_name)
+ else:
+ for field, errors in form.errors.items():
+ field_label = getattr(getattr(form, field, None), "label", None)
+ label_text = field_label.text if field_label else
field.replace("_", " ").title()
+ for error in errors:
+ await quart.flash(f"{label_text}: {error}", "warning")
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 20d29ac..c022a6f 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -148,7 +148,7 @@ async def view_path(
"""View the content of a specific file in the final release."""
release = await session.release(project_name, version_name)
_max_view_size = 1 * 1024 * 1024
- full_path = util.get_release_dir() / project_name / version_name /
file_path
+ full_path = util.release_directory(release) / file_path
content_listing = await util.archive_listing(full_path)
content, is_text, is_truncated, error_message = await
util.read_file_for_viewer(full_path, _max_view_size)
return await quart.render_template(
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 61e86d0..436e0e7 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -59,6 +59,7 @@ async def draft_checks(
) -> int:
"""Core logic to analyse a draft revision and queue checks."""
# Construct path to the specific revision
+ # We don't have the release object here, so we can't use
util.release_directory
revision_path = util.get_release_candidate_draft_dir() / project_name /
release_version / draft_revision
relative_paths = await util.paths_recursive(revision_path)
diff --git a/atr/templates/preview-refine-release.html
b/atr/templates/preview-refine-release.html
index 7c337fa..8f97c02 100644
--- a/atr/templates/preview-refine-release.html
+++ b/atr/templates/preview-refine-release.html
@@ -57,15 +57,48 @@
</div>
</div>
- <div class="card mb-4 shadow-sm">
- <div class="card-header bg-light">
- <h3 class="card-title mb-0">TODO</h3>
+ <div class="alert alert-warning mb-4" role="alert">
+ <p class="fw-semibold mb-1">TODO</p>
+ <p class="mb-1">
+ Planned enhancements include removing "RC" tags from filenames (not yet
implemented) and improving the file movement interface, as the current
dropdowns are unsuitable for large releases.
+ </p>
+ </div>
+
+ {% if can_move %}
+ <div class="card mb-4">
+ <div class="card-header bg-light">
+ <h3 class="mb-0">Move file to different directory</h3>
+ </div>
+ <div class="card-body">
+ <form method="post" class="atr-canary">
+ {{ form.hidden_tag() }}
+ <div class="mb-3">
+ {{ form.source_file.label(class="form-label") }}
+ {{ form.source_file(class="form-select form-select-sm
font-monospace") }}
+ {% if form.source_file.errors %}
+ <div class="invalid-feedback d-block">
+ {% for error in form.source_file.errors %}{{ error }}{% endfor
%}
+ </div>
+ {% endif %}
+ </div>
+ <div class="mb-3">
+ {{ form.target_directory.label(class="form-label") }}
+ {{ form.target_directory(class="form-select form-select-sm
font-monospace") }}
+ {% if form.target_directory.errors %}
+ <div class="invalid-feedback d-block">
+ {% for error in form.target_directory.errors %}{{ error }}{%
endfor %}
+ </div>
+ {% endif %}
+ </div>
+ {{ form.submit(class="btn btn-primary btn-sm") }}
+ </form>
+ </div>
</div>
- <div class="card-body">
- <p>We plan to allow moving files and removing "RC" tags from files on
this page.</p>
- <p>For now, you can only move straight to the announce page.</p>
+ {% else %}
+ <div class="alert alert-info" role="alert">
+ File moving is disabled as all files are currently in the same directory
or the revision is empty.
</div>
- </div>
+ {% endif %}
{% endblock content %}
{% block javascripts %}
diff --git a/atr/util.py b/atr/util.py
index 7c3f276..0963120 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -395,6 +395,13 @@ async def read_file_for_viewer(full_path: pathlib.Path,
max_size: int) -> tuple[
def release_directory(release: models.Release) -> pathlib.Path:
+ """Return the absolute path to the directory containing the active files
for a given release phase."""
+ if release.revision is None:
+ return release_directory_base(release)
+ return release_directory_base(release) / release.revision
+
+
+def release_directory_base(release: models.Release) -> pathlib.Path:
"""Determine the filesystem directory for a given release based on its
phase."""
phase = release.phase
try:
diff --git a/playwright/test.py b/playwright/test.py
index b1ffafc..93b4f9d 100644
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -206,12 +206,23 @@ def lifecycle_05_resolve_vote(page: sync_api.Page,
credentials: Credentials, ver
sync_api.expect(submit_button_locator).to_be_enabled()
submit_button_locator.click()
- logging.info(f"Waiting for navigation to
/announce/tooling-test-example/{version_name} after resolving the vote")
- wait_for_path(page, f"/announce/tooling-test-example/{version_name}")
+ logging.info(f"Waiting for navigation to
/refine/tooling-test-example/{version_name} after resolving the vote")
+ wait_for_path(page, f"/refine/tooling-test-example/{version_name}")
logging.info("Vote resolution actions completed successfully")
def lifecycle_06_announce_preview(page: sync_api.Page, credentials:
Credentials, version_name: str) -> None:
+ go_to_path(page, f"/refine/tooling-test-example/{version_name}")
+ logging.info("Refine page loaded successfully")
+
+ logging.info(f"Locating the announce link for tooling-test-example
{version_name}")
+ announce_link_locator =
page.locator(f'a[href="/announce/tooling-test-example/{esc_id(version_name)}"]')
+ sync_api.expect(announce_link_locator).to_be_visible()
+ announce_link_locator.click()
+
+ logging.info(f"Waiting for navigation to
/announce/tooling-test-example/{version_name} after announcing preview")
+ wait_for_path(page, f"/announce/tooling-test-example/{version_name}")
+
logging.info(f"Locating the announcement form for tooling-test-example
{version_name}")
form_locator = page.locator('form[action="/preview/announce"]')
sync_api.expect(form_locator).to_be_visible()
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]