This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new 957a90b1 Add an age and inactivity column to the releases list for
admins
957a90b1 is described below
commit 957a90b13b7db9a142f3d8fda38977e79cce523e
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Apr 6 16:19:20 2026 +0100
Add an age and inactivity column to the releases list for admins
---
atr/admin/__init__.py | 42 +++++++++++-
atr/admin/templates/all-releases.html | 8 ++-
tests/unit/test_admin_all_releases.py | 119 ++++++++++++++++++++++++++++++++++
3 files changed, 166 insertions(+), 3 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 5b65e6d8..f4887ef2 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -65,6 +65,8 @@ import atr.web as web
ROUTES_MODULE: Final[Literal[True]] = True
+_MAXIMUM_PERMITTED_AGE_DAYS: Final = datetime.timedelta(days=90)
+
class BrowseAsUserForm(form.Form):
uid: str = form.label("ASF UID", "Enter the ASF UID to browse as.")
@@ -94,6 +96,14 @@ class LdapLookupForm(form.Form):
)
+class ReleaseAgeRow(NamedTuple):
+ release: sql.Release
+ age_label: str
+ age_bold: bool
+ inactive_label: str
+ inactive_bold: bool
+
+
class RevokeUserTokensForm(form.Form):
asf_uid: str = form.label("ASF UID", "Enter the ASF UID whose tokens
should be revoked.")
confirm_revoke: Literal["REVOKE"] = form.label("Confirmation", "Type
REVOKE to confirm.")
@@ -134,8 +144,10 @@ async def all_releases(_session: web.Committer,
_all_releases: Literal["all/rele
Display a list of all releases across all phases.
"""
async with db.session() as data:
- releases = await data.release(_project=True,
_committee=True).order_by(sql.Release.key).all()
- return await template.render("all-releases.html", releases=releases,
release_as_url=mapping.release_as_url)
+ releases = await data.release(_project=True, _committee=True,
_revisions=True).order_by(sql.Release.key).all()
+ now = datetime.datetime.now(datetime.UTC)
+ release_rows = [_release_age_row(release, now) for release in releases]
+ return await template.render("all-releases.html",
release_rows=release_rows, release_as_url=mapping.release_as_url)
@admin.typed
@@ -1314,6 +1326,32 @@ async def
_get_filesystem_dirs_unfinished(filesystem_dirs: list[str]) -> None:
filesystem_dirs.append(version_dir_path)
+def _last_activity_at(release: sql.Release) -> datetime.datetime:
+ candidates: list[datetime.datetime] = [release.created]
+ if release.revisions:
+ candidates.append(release.revisions[-1].created)
+ if release.vote_started is not None:
+ candidates.append(release.vote_started)
+ if release.vote_resolved is not None:
+ candidates.append(release.vote_resolved)
+ if release.released is not None:
+ candidates.append(release.released)
+ return max(candidates)
+
+
+def _release_age_row(release: sql.Release, now: datetime.datetime) ->
ReleaseAgeRow:
+ age = now - release.created
+ last_activity = _last_activity_at(release)
+ inactive_for = now - last_activity
+ return ReleaseAgeRow(
+ release=release,
+ age_label=util.plural(age.days, "day"),
+ age_bold=age > _MAXIMUM_PERMITTED_AGE_DAYS,
+ inactive_label=util.plural(inactive_for.days, "day"),
+ inactive_bold=inactive_for > _MAXIMUM_PERMITTED_AGE_DAYS,
+ )
+
+
def _require_non_production_mode() -> None:
if config.is_production_mode():
quart.abort(404)
diff --git a/atr/admin/templates/all-releases.html
b/atr/admin/templates/all-releases.html
index ff426982..600b72c5 100644
--- a/atr/admin/templates/all-releases.html
+++ b/atr/admin/templates/all-releases.html
@@ -15,10 +15,12 @@
<th>Project</th>
<th>Phase</th>
<th>Created</th>
+ <th>Age & inactivity</th>
</tr>
</thead>
<tbody>
- {% for release in releases %}
+ {% for row in release_rows %}
+ {% set release = row.release %}
<tr>
<td>
{% if release.project %}
@@ -45,6 +47,10 @@
{% endif %}
</td>
<td>{{ release.created.strftime("%Y-%m-%d") }}<br>{{
release.created.strftime("%H:%M:%S UTC") }}</td>
+ <td>
+ <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>
</tr>
{% else %}
<tr>
diff --git a/tests/unit/test_admin_all_releases.py
b/tests/unit/test_admin_all_releases.py
new file mode 100644
index 00000000..046f20c5
--- /dev/null
+++ b/tests/unit/test_admin_all_releases.py
@@ -0,0 +1,119 @@
+# 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.
+
+import datetime
+from types import SimpleNamespace
+
+import atr.admin as admin_routes
+
+
+def test_last_activity_at_created_only() -> None:
+ created = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
+ release = _make_release(created=created)
+ assert admin_routes._last_activity_at(release) == created
+
+
+def test_last_activity_at_released_newest() -> None:
+ created = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
+ released = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
+ release = _make_release(
+ created=created,
+ vote_started=datetime.datetime(2025, 2, 1, tzinfo=datetime.UTC),
+ vote_resolved=datetime.datetime(2025, 3, 1, tzinfo=datetime.UTC),
+ released=released,
+ )
+ assert admin_routes._last_activity_at(release) == released
+
+
+def test_last_activity_at_released_no_revisions() -> None:
+ created = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
+ released = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
+ release = _make_release(created=created, released=released)
+ assert admin_routes._last_activity_at(release) == released
+
+
+def test_last_activity_at_revision_newest() -> None:
+ created = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
+ rev1 = _make_revision(datetime.datetime(2025, 1, 5, tzinfo=datetime.UTC))
+ rev2 = _make_revision(datetime.datetime(2025, 2, 10, tzinfo=datetime.UTC))
+ release = _make_release(created=created, revisions=[rev1, rev2])
+ assert admin_routes._last_activity_at(release) == rev2.created
+
+
+def test_last_activity_at_vote_started_newest() -> None:
+ created = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC)
+ rev = _make_revision(datetime.datetime(2025, 1, 5, tzinfo=datetime.UTC))
+ vote_started = datetime.datetime(2025, 2, 1, tzinfo=datetime.UTC)
+ release = _make_release(created=created, revisions=[rev],
vote_started=vote_started)
+ assert admin_routes._last_activity_at(release) == vote_started
+
+
+def test_release_age_row_bold_at_91_days() -> None:
+ now = datetime.datetime(2025, 7, 1, tzinfo=datetime.UTC)
+ created = now - datetime.timedelta(days=91)
+ release = _make_release(created=created)
+ row = admin_routes._release_age_row(release, now)
+ assert row.age_bold is True
+ assert row.inactive_bold is True
+
+
+def test_release_age_row_bold_just_over_90_days() -> None:
+ now = datetime.datetime(2025, 7, 1, tzinfo=datetime.UTC)
+ created = now - datetime.timedelta(days=90, seconds=1)
+ release = _make_release(created=created)
+ row = admin_routes._release_age_row(release, now)
+ assert row.age_bold is True
+ assert row.age_label == "90 days"
+
+
+def test_release_age_row_inactive_not_bold_despite_old_age() -> None:
+ now = datetime.datetime(2025, 7, 1, tzinfo=datetime.UTC)
+ created = now - datetime.timedelta(days=120)
+ rev = _make_revision(now - datetime.timedelta(days=10))
+ release = _make_release(created=created, revisions=[rev])
+ row = admin_routes._release_age_row(release, now)
+ assert row.age_bold is True
+ assert row.inactive_bold is False
+
+
+def test_release_age_row_not_bold_at_90_days() -> None:
+ now = datetime.datetime(2025, 7, 1, tzinfo=datetime.UTC)
+ created = now - datetime.timedelta(days=90)
+ release = _make_release(created=created)
+ row = admin_routes._release_age_row(release, now)
+ assert row.age_bold is False
+ assert row.inactive_bold is False
+
+
+def _make_release(
+ created: datetime.datetime,
+ revisions: list[SimpleNamespace] | None = None,
+ vote_started: datetime.datetime | None = None,
+ vote_resolved: datetime.datetime | None = None,
+ released: datetime.datetime | None = None,
+) -> SimpleNamespace:
+ return SimpleNamespace(
+ created=created,
+ revisions=revisions or [],
+ vote_started=vote_started,
+ vote_resolved=vote_resolved,
+ released=released,
+ )
+
+
+def _make_revision(created: datetime.datetime) -> SimpleNamespace:
+ return SimpleNamespace(created=created)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]