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
commit 1753b22c8207990add9b937670245e9f7231c980 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..26bd5c89 --- /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 + + +def test_last_activity_at_created_only() -> None: + created = datetime.datetime(2025, 1, 1, tzinfo=datetime.UTC) + release = _make_release(created=created) + assert admin._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._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._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._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._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._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._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._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._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]
