You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/09/01 13:55:11 UTC
(tooling-trusted-releases) branch arm updated (649c80e2 -> d4098705)
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a change to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
discard 649c80e2 #1340 - improve release deletion and admin release list
new d4098705 #1340 - improve release deletion and admin release list
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (649c80e2)
\
N -- N -- N refs/heads/arm (d4098705)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
atr/admin/templates/current-release-delete.html | 20 +++++
atr/admin/templates/current-releases.html | 106 ++++++++++++++++++++++++
2 files changed, 126 insertions(+)
create mode 100644 atr/admin/templates/current-release-delete.html
create mode 100644 atr/admin/templates/current-releases.html
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: #1340 - improve release deletion and admin release list
Posted by ar...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit d4098705e28cd3a435a67fa3e00251ea9a380ee9
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Tue Sep 1 14:38:02 2026 +0100
#1340 - improve release deletion and admin release list
---
atr/admin/__init__.py | 196 ++++++++++-----------
atr/admin/templates/current-release-delete.html | 20 +++
.../{all-releases.html => current-releases.html} | 30 +++-
atr/admin/templates/delete-release.html | 16 --
atr/db/__init__.py | 4 +
atr/templates/includes/topnav.html | 9 +-
playwright/test.py | 58 +++---
tests/e2e/helpers.py | 14 +-
8 files changed, 173 insertions(+), 174 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 129bb292..34b0efd7 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -93,17 +93,9 @@ class DeleteCommitteeKeysForm(form.Form):
confirm_delete: Literal["DELETE KEYS"] = form.label("Confirmation", "Type DELETE KEYS to confirm.")
-class DeleteReleaseForm(form.Form):
- releases_to_delete: form.StrList = form.label("Select releases to delete", widget=form.Widget.CUSTOM)
+class DeleteReleaseConfirmForm(form.Form):
confirm_delete: Literal["DELETE"] = form.label("Confirmation", "Type DELETE to confirm.")
- @pydantic.field_validator("releases_to_delete")
- @classmethod
- def validate_releases_to_delete(cls, v: form.StrList) -> form.StrList:
- if len(v) == 0:
- raise ValueError("You must select at least one release to delete")
- return v
-
class EditBannerForm(form.Form):
banner_markdown: str = form.label(
@@ -233,40 +225,6 @@ class TestRosterResetForm(form.Form):
type TestRosterForm = Annotated[TestRosterSetForm | TestRosterRemoveForm | TestRosterResetForm, form.DISCRIMINATOR]
-@admin.typed
-async def all_releases(_session: web.Committer, _all_releases: Literal["all/releases"], query_args: PageQuery) -> str:
- """
- URL: GET /all/releases
-
- Display a list of all releases across all phases.
- """
- try:
- validation.pagination_args_validate(query_args)
- except ValueError as e:
- raise exceptions.BadRequest(str(e))
- async with db.session() as data:
- count_result = await data.execute(sqlalchemy.select(sqlalchemy.func.count()).select_from(sql.Release))
- count = count_result.scalar_one()
- releases = await (
- data.release(_project=True, _committee=True, _revisions=True)
- .order_by(sql.Release.key)
- .limit(query_args.limit)
- .offset(query_args.offset)
- .all()
- )
- now = datetime.datetime.now(datetime.UTC)
- release_rows = [_release_age_row(release, now) for release in releases]
- page = _page_nav(query_args.offset, query_args.limit, count, len(releases))
- return await template.render(
- "all-releases.html",
- release_rows=release_rows,
- release_as_url=mapping.release_as_url,
- count=count,
- limit=query_args.limit,
- page=page,
- )
-
-
@admin.typed
async def banner_get(session: web.Committer, _banner: Literal["banner"]) -> str:
async with storage.write(session) as write:
@@ -1541,6 +1499,96 @@ async def consistency(_session: web.Committer, _consistency: Literal["consistenc
)
+@admin.typed
+async def current_release_delete_get(
+ _session: web.Committer,
+ _current_releases: Literal["current/releases"],
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ _delete: Literal["delete"],
+) -> str:
+ """
+ URL: GET /current/releases/<project_key>/<version_key>/delete
+
+ Confirm permanent deletion of a single release and its files.
+ """
+ release_key = sql.release_key(str(project_key), str(version_key))
+ async with db.session() as data:
+ release = await data.release(key=release_key, _project=True).demand(exceptions.NotFound("Release not found"))
+ rendered_form = await form.render(
+ model_cls=DeleteReleaseConfirmForm,
+ submit_label="Delete this release permanently",
+ submit_classes="btn-danger",
+ )
+ return await template.render(
+ "current-release-delete.html",
+ release=release,
+ form=rendered_form,
+ )
+
+
+@admin.typed
+async def current_release_delete_post(
+ session: web.Committer,
+ _current_releases: Literal["current/releases"],
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ _delete: Literal["delete"],
+ _delete_form: DeleteReleaseConfirmForm,
+) -> web.WerkzeugResponse:
+ """
+ URL: POST /current/releases/<project_key>/<version_key>/delete
+
+ Permanently delete a single release and its associated data and files.
+ """
+ release_key = sql.release_key(str(project_key), str(version_key))
+ await _delete_releases(session, [release_key])
+ return await session.redirect(current_releases)
+
+
+@admin.typed
+async def current_releases(
+ _session: web.Committer, _current_releases: Literal["current/releases"], query_args: PageQuery
+) -> str:
+ """
+ URL: GET /current/releases
+
+ Display live release-workflow releases across all phases. Catalogued releases
+ (those with no workflow revisions) live in the catalog admin pages instead.
+ """
+ try:
+ validation.pagination_args_validate(query_args)
+ except ValueError as e:
+ raise exceptions.BadRequest(str(e))
+ # A release is "current" when it has at least one workflow revision; the
+ # imported catalogue has none, so this is the line between the two surfaces
+ has_revisions = sql.latest_revision_number_query().is_not(None)
+ async with db.session() as data:
+ count_result = await data.execute(
+ sqlalchemy.select(sqlalchemy.func.count()).select_from(sql.Release).where(has_revisions)
+ )
+ count = count_result.scalar_one()
+ releases = await (
+ data.release(_project=True, _committee=True, _revisions=True)
+ .where(has_revisions)
+ .order_by(sql.Release.key)
+ .limit(query_args.limit)
+ .offset(query_args.offset)
+ .all()
+ )
+ now = datetime.datetime.now(datetime.UTC)
+ release_rows = [_release_age_row(release, now) for release in releases]
+ page = _page_nav(query_args.offset, query_args.limit, count, len(releases))
+ return await template.render(
+ "current-releases.html",
+ release_rows=release_rows,
+ release_as_url=mapping.release_as_url,
+ count=count,
+ limit=query_args.limit,
+ page=page,
+ )
+
+
@admin.typed
async def data(session: web.Committer, _data: Literal["data"], query_args: PageQuery) -> str:
"""
@@ -1631,68 +1679,6 @@ async def delete_committee_keys_post(
return await session.redirect(delete_committee_keys_get)
-@admin.typed
-async def delete_release_get(
- _session: web.Committer, _delete_release_get: Literal["delete-release"]
-) -> str | web.WerkzeugResponse:
- """
- URL: GET /delete-release
-
- Display the form to delete releases.
- """
- async with db.session() as data:
- releases = await data.release(_project=True).order_by(sql.Release.key).all()
-
- if releases:
- releases_widget = htpy.div[
- [
- htpy.div(".form-check")[
- htpy.input(
- class_="form-check-input",
- type="checkbox",
- name="releases_to_delete",
- value=release.key,
- id=f"release_{release.key}",
- ),
- htpy.label(".form-check-label", for_=f"release_{release.key}")[
- htpy.strong[release.key],
- f" ({release.project.display_name}, {release.phase.value.upper()})",
- ],
- ]
- for release in releases
- ]
- ]
- block = htm.Block(htm.div)
- block.append(releases_widget)
- block.div(".form-text.mt-1")["Select one or more releases to delete permanently."]
- releases_widget_with_help = block.collect()
- else:
- releases_widget_with_help = htpy.p(".text-muted")["No releases found in the database."]
-
- rendered_form = await form.render(
- model_cls=DeleteReleaseForm,
- submit_label="Delete selected releases permanently",
- submit_classes="btn-danger",
- custom={"releases_to_delete": releases_widget_with_help},
- )
-
- return await template.render("delete-release.html", form=rendered_form)
-
-
-@admin.typed
-async def delete_release_post(
- session: web.Committer, _delete_release_get: Literal["delete-release"], delete_form: DeleteReleaseForm
-) -> str | web.WerkzeugResponse:
- """
- URL: POST /delete-release
-
- Delete selected releases and their associated data and files.
- """
- await _delete_releases(session, delete_form.releases_to_delete)
-
- return await session.redirect(delete_release_get)
-
-
@admin.typed
async def delete_test_openpgp_keys_get(
_session: web.Committer, _delete_test_openpgp_keys: Literal["delete-test-openpgp-keys"]
diff --git a/atr/admin/templates/current-release-delete.html b/atr/admin/templates/current-release-delete.html
new file mode 100644
index 00000000..16ae8ede
--- /dev/null
+++ b/atr/admin/templates/current-release-delete.html
@@ -0,0 +1,20 @@
+{% extends "layouts/base-admin.html" %}
+
+{%- block title -%}Delete {{ release.key }} ~ ATR Admin{%- endblock title -%}
+
+{%- block description -%}Confirm permanent deletion of a release.{%- endblock description -%}
+
+{% block content %}
+ <h1>Delete release</h1>
+ <p><a href="{{ as_url(admin.current_releases) }}">Back to current releases</a></p>
+
+ <div class="alert alert-danger" role="alert">
+ <p class="mb-1"><strong>This permanently deletes {{ release.key }}.</strong></p>
+ <p class="mb-0">
+ Its database records, background tasks, check results, and files on disk
+ are all removed. This cannot be undone.
+ </p>
+ </div>
+
+ {{ form }}
+{% endblock content %}
diff --git a/atr/admin/templates/all-releases.html b/atr/admin/templates/current-releases.html
similarity index 70%
rename from atr/admin/templates/all-releases.html
rename to atr/admin/templates/current-releases.html
index 70ef788c..26967f32 100644
--- a/atr/admin/templates/all-releases.html
+++ b/atr/admin/templates/current-releases.html
@@ -1,11 +1,17 @@
{% extends "layouts/base-admin.html" %}
-{%- block title -%}All releases ~ ATR Admin{%- endblock title -%}
+{%- block title -%}Current releases ~ ATR Admin{%- endblock title -%}
-{%- block description -%}View all releases across all stages and phases, for debugging.{%- endblock description -%}
+{%- block description -%}Live release-workflow releases across all phases.{%- endblock description -%}
{% block content %}
- <h1>All releases</h1>
+ <h1>Current releases</h1>
+
+ <div class="alert alert-info" role="alert">
+ These are live release-workflow releases, across all phases. Historical
+ catalogued releases (those with no workflow revisions) are managed in
+ <a href="{{ as_url(admin.catalog_get) }}" class="alert-link">Catalog admin</a>.
+ </div>
{% if release_rows %}
<p>Showing {{ page.start }} to {{ page.end }} of {{ count }} releases.</p>
@@ -20,6 +26,7 @@
<th>Phase</th>
<th>Created</th>
<th>Age & inactivity</th>
+ <th>Actions</th>
</tr>
</thead>
<tbody>
@@ -37,7 +44,6 @@
<td>{{ release.project.key if release.project else "N/A" }}</td>
<td>
{% set phase_value = release.phase.value %}
- <!-- {{ phase_value }} <br> -->
{% if phase_value == "release_candidate_draft" %}
<span class="badge bg-secondary">1. Draft</span>
{% elif phase_value == "release_candidate" %}
@@ -55,13 +61,19 @@
<span{% if row.age_bold %} class="fw-bold"{% endif %}>{{ row.age_label }}</span><br>
<small class="text-muted{% if row.inactive_bold %} fw-bold{% endif %}">{{ row.inactive_label }}</small>
</td>
+ <td>
+ <a class="btn btn-sm btn-outline-danger"
+ href="{{ as_url(admin.current_release_delete_get, project_key=release.project_key, version_key=release.version) }}">
+ <i class="bi bi-trash"></i> Delete
+ </a>
+ </td>
</tr>
{% else %}
<tr>
{% if count %}
- <td colspan="6">No releases to show on this page ({{ count }} total).</td>
+ <td colspan="7">No releases to show on this page ({{ count }} total).</td>
{% else %}
- <td colspan="6">No releases found.</td>
+ <td colspan="7">No current releases found.</td>
{% endif %}
</tr>
{% endfor %}
@@ -69,12 +81,12 @@
</table>
{% if (page.previous_offset is not none) or (page.next_offset is not none) %}
- <nav aria-label="All releases pages">
+ <nav aria-label="Current releases pages">
<ul class="pagination">
{% if page.previous_offset is not none %}
<li class="page-item">
<a class="page-link"
- href="{{ as_url(admin.all_releases, limit=limit, offset=page.previous_offset) }}">Previous</a>
+ href="{{ as_url(admin.current_releases, limit=limit, offset=page.previous_offset) }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
@@ -82,7 +94,7 @@
{% if page.next_offset is not none %}
<li class="page-item">
<a class="page-link"
- href="{{ as_url(admin.all_releases, limit=limit, offset=page.next_offset) }}">Next</a>
+ href="{{ as_url(admin.current_releases, limit=limit, offset=page.next_offset) }}">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
diff --git a/atr/admin/templates/delete-release.html b/atr/admin/templates/delete-release.html
deleted file mode 100644
index 9e655f9e..00000000
--- a/atr/admin/templates/delete-release.html
+++ /dev/null
@@ -1,16 +0,0 @@
-{% extends "layouts/base-admin.html" %}
-
-{%- block title -%}Delete release ~ ATR Admin{%- endblock title -%}
-
-{%- block description -%}Permanently delete a release and all associated data.{%- endblock description -%}
-
-{% block content %}
- <h1>Delete release</h1>
-
- <div class="alert alert-danger" role="alert">
- <strong>Warning:</strong> This action is irreversible. Deleting a release will permanently remove its database records, including tasks and check results, and its associated files from the filesystem.
- </div>
-
- {{ form }}
-
-{% endblock content %}
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 83a847db..f1ece8cb 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -93,6 +93,10 @@ class Query[T]:
self.query = self.query.order_by(*args, **kwargs)
return self
+ def where(self, *args: Any, **kwargs: Any) -> Query[T]:
+ self.query = self.query.where(*args, **kwargs)
+ return self
+
def limit(self, count: int) -> Query[T]:
self.query = self.query.limit(count)
return self
diff --git a/atr/templates/includes/topnav.html b/atr/templates/includes/topnav.html
index b3cc4057..f8f4b9b6 100644
--- a/atr/templates/includes/topnav.html
+++ b/atr/templates/includes/topnav.html
@@ -264,13 +264,8 @@
</li>
<li>
<a class="dropdown-item"
- href="{{ as_url(admin.all_releases) }}"
- {% if request.endpoint == 'atr_admin_all_releases' %}class="active"{% endif %}><i class="bi bi-list-ul"></i> All releases</a>
- </li>
- <li>
- <a class="dropdown-item"
- href="{{ as_url(admin.delete_release_get) }}"
- {% if request.endpoint == 'atr_admin_delete_release_get' %}class="active"{% endif %}><i class="bi bi-trash"></i> Delete release</a>
+ href="{{ as_url(admin.current_releases) }}"
+ {% if request.endpoint == 'atr_admin_current_releases' %}class="active"{% endif %}><i class="bi bi-list-ul"></i> Current releases</a>
</li>
<li>
<a class="dropdown-item"
diff --git a/playwright/test.py b/playwright/test.py
index 1187bc29..ce34aaba 100755
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -538,34 +538,34 @@ def ensure_note_results_are_visible(page: Page, result_type: str) -> None:
expect(first_note_row).not_to_have_class("atr-hide", timeout=1000)
-def release_remove(page: Page, release_key: str) -> None:
+def release_remove(page: Page, project_key: str, version_key: str) -> None:
+ release_key = f"{project_key}-{version_key}"
logging.info(f"Checking whether the {release_key} release exists")
- release_checkbox_locator = page.locator(f'input[name="releases_to_delete"][value="{release_key}"]')
+ # The confirm page 404s when the release is gone, so a missing confirm field
+ # means there is nothing to delete
+ go_to_path(page, f"/admin/current/releases/{project_key}/{version_key}/delete")
+ confirm_locator = page.locator('input[name="confirm_delete"]')
- if release_checkbox_locator.is_visible():
- logging.info(f"Found the {release_key} release, proceeding with deletion")
- logging.info(f"Selecting {release_key} for deletion")
- release_checkbox_locator.check()
+ if confirm_locator.count() == 0:
+ logging.info(f"Could not find the {release_key} release, no deletion needed")
+ return
- logging.info(f"Filling deletion confirmation for {release_key}")
- page.locator("#confirm_delete").fill("DELETE")
+ logging.info(f"Found the {release_key} release, proceeding with deletion")
+ confirm_locator.fill("DELETE")
- logging.info(f"Submitting deletion form for {release_key}")
- submit_button_locator = page.get_by_role("button", name="Delete selected releases permanently")
- expect(submit_button_locator).to_be_enabled()
- submit_button_locator.click()
+ logging.info(f"Submitting deletion form for {release_key}")
+ submit_button_locator = page.get_by_role("button", name="Delete this release permanently")
+ expect(submit_button_locator).to_be_enabled()
+ submit_button_locator.click()
- logging.info(f"Waiting for page load after deletion submission for {release_key}")
- page.wait_for_load_state()
- logging.info(f"Page loaded after deletion for {release_key}")
+ logging.info(f"Waiting for page load after deletion submission for {release_key}")
+ page.wait_for_load_state()
- logging.info(f"Checking for success flash message for {release_key}")
- flash_message_locator = page.locator("div.flash-success")
- expect(flash_message_locator).to_be_visible()
- expect(flash_message_locator).to_contain_text("Successfully deleted 1 release")
- logging.info(f"Deletion successful for {release_key}")
- else:
- logging.info(f"Could not find the {release_key} release, no deletion needed")
+ logging.info(f"Checking for success flash message for {release_key}")
+ flash_message_locator = page.locator("div.flash-success")
+ expect(flash_message_locator).to_be_visible()
+ expect(flash_message_locator).to_contain_text("Successfully deleted 1 release")
+ logging.info(f"Deletion successful for {release_key}")
def run_tests(skip_slow: bool, tidy_after: bool) -> None:
@@ -1614,16 +1614,14 @@ def test_tidy_up_ssh_keys_continued(page: Page, credentials: Credentials, finger
def test_tidy_up_releases(page: Page) -> None:
- logging.info("Navigating to the admin delete release page")
- go_to_path(page, "/admin/delete-release")
- logging.info("Admin delete release page loaded")
+ logging.info("Tidying up test releases")
# TODO: Get these names automatically
- release_remove(page, f"{TEST_PROJECT}-0.1+draft")
- release_remove(page, f"{TEST_PROJECT}-0.1+candidate")
- release_remove(page, f"{TEST_PROJECT}-0.1+preview")
- release_remove(page, f"{TEST_PROJECT}-0.1+release")
- release_remove(page, f"{TEST_PROJECT}-0.2")
+ release_remove(page, TEST_PROJECT, "0.1+draft")
+ release_remove(page, TEST_PROJECT, "0.1+candidate")
+ release_remove(page, TEST_PROJECT, "0.1+preview")
+ release_remove(page, TEST_PROJECT, "0.1+release")
+ release_remove(page, TEST_PROJECT, "0.2")
def wait_for_path(page: Page, path: str, timeout: int = 30000) -> None:
diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py
index 5323cb9c..31bcd195 100644
--- a/tests/e2e/helpers.py
+++ b/tests/e2e/helpers.py
@@ -44,14 +44,14 @@ def api_post(request: APIRequestContext, path: str, data: dict[str, Any]) -> dic
def delete_release_if_exists(page: Page, project_key: str, version_key: str) -> None:
- release_key = f"{project_key}-{version_key}"
- visit(page, "/admin/delete-release")
- checkbox = page.locator(f'input[name="releases_to_delete"][value="{release_key}"]')
- if checkbox.count() == 0:
+ # The confirm page 404s when the release is gone, so a missing confirm field
+ # means there is nothing to delete
+ visit(page, f"/admin/current/releases/{project_key}/{version_key}/delete")
+ confirm = page.locator('input[name="confirm_delete"]')
+ if confirm.count() == 0:
return
- checkbox.check()
- page.locator('input[name="confirm_delete"]').fill("DELETE")
- page.get_by_role("button", name="Delete selected releases permanently").click()
+ confirm.fill("DELETE")
+ page.get_by_role("button", name="Delete this release permanently").click()
page.wait_for_load_state()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org