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 &amp; 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]

Reply via email to