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 b242788 Add active, dormant, retired, and standing project statuses
b242788 is described below
commit b2427888b56c8fba446bf16a21edfbfdb80f4c29
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Jun 12 20:18:26 2025 +0100
Add active, dormant, retired, and standing project statuses
---
atr/blueprints/admin/admin.py | 25 ++++++++---------
atr/construct.py | 12 ++++-----
atr/db/__init__.py | 8 +++---
atr/db/models.py | 10 +++++--
atr/routes/committees.py | 2 +-
atr/routes/keys.py | 4 +--
atr/routes/preview.py | 2 +-
atr/routes/projects.py | 6 ++---
atr/routes/release.py | 4 +--
atr/routes/start.py | 4 +--
atr/ssh.py | 8 +++---
atr/templates/committee-directory.html | 12 ++++++---
atr/templates/project-view.html | 6 ++---
atr/templates/projects.html | 6 ++---
atr/user.py | 2 +-
atr/util.py | 9 ++++---
migrations/versions/0010_2025.06.12_3b27ab22.py | 36 +++++++++++++++++++++++++
17 files changed, 102 insertions(+), 54 deletions(-)
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 608e3dd..6b1a9c0 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -677,16 +677,17 @@ async def _process_undiscovered(data: db.Session) ->
tuple[int, int]:
return added_count, updated_count
-def _project_is_retired(pmc: models.Committee, project_name: str,
project_status: apache.ProjectStatus) -> bool:
- if util.committee_without_releases(pmc.name):
- return True
+def _project_status(
+ pmc: models.Committee, project_name: str, project_status:
apache.ProjectStatus
+) -> models.ProjectStatus:
+ if pmc.name == "attic":
+ # This must come first, because attic is also a standing committee
+ return models.ProjectStatus.RETIRED
elif ("_dormant_" in project_name) or
project_status.name.endswith("(Dormant)"):
- # TODO: Dormant is not quite the same thing as retired
- # But we can act on the assumption that projects can be unretired
- # The Project.is_retired setting is not permanent
- # We have to check the names because projects.json does not have a
dormant property
- return True
- return False
+ return models.ProjectStatus.DORMANT
+ elif util.committee_is_standing(pmc.name):
+ return models.ProjectStatus.STANDING
+ return models.ProjectStatus.ACTIVE
def _session_data(
@@ -875,13 +876,13 @@ async def _update_projects(data: db.Session, projects:
apache.ProjectsData) -> t
project_model = await data.project(name=project_name).get()
# Check whether the project is retired, whether temporarily or
otherwise
- is_retired = _project_is_retired(pmc, project_name, project_status)
+ status = _project_status(pmc, project_name, project_status)
if not project_model:
- project_model = models.Project(name=project_name, committee=pmc,
is_retired=is_retired)
+ project_model = models.Project(name=project_name, committee=pmc,
status=status)
data.add(project_model)
added_count += 1
else:
- project_model.is_retired = is_retired
+ project_model.status = status
updated_count += 1
project_model.full_name = project_status.name
diff --git a/atr/construct.py b/atr/construct.py
index 929a047..02704f1 100644
--- a/atr/construct.py
+++ b/atr/construct.py
@@ -83,9 +83,9 @@ async def announce_release_body(body: str, options:
AnnounceReleaseOptions) -> s
async def announce_release_default(project_name: str) -> str:
async with db.session() as data:
- project = await data.project(name=project_name, is_retired=False,
_release_policy=True).demand(
- RuntimeError(f"Project {project_name} not found")
- )
+ project = await data.project(
+ name=project_name, status=models.ProjectStatus.ACTIVE,
_release_policy=True
+ ).demand(RuntimeError(f"Project {project_name} not found"))
return project.policy_announce_release_template
@@ -137,8 +137,8 @@ async def start_vote_body(body: str, options:
StartVoteOptions) -> str:
async def start_vote_default(project_name: str) -> str:
async with db.session() as data:
- project = await data.project(name=project_name, is_retired=False,
_release_policy=True).demand(
- RuntimeError(f"Project {project_name} not found")
- )
+ project = await data.project(
+ name=project_name, status=models.ProjectStatus.ACTIVE,
_release_policy=True
+ ).demand(RuntimeError(f"Project {project_name} not found"))
return project.policy_start_vote_template
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index a199e94..0e48e90 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -272,7 +272,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
full_name: Opt[str] = NOT_SET,
committee_name: Opt[str] = NOT_SET,
release_policy_id: Opt[int] = NOT_SET,
- is_retired: Opt[bool] = NOT_SET,
+ status: Opt[models.ProjectStatus] = NOT_SET,
_committee: bool = True,
_releases: bool = False,
_distribution_channels: bool = False,
@@ -290,8 +290,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
query = query.where(models.Project.committee_name ==
committee_name)
if is_defined(release_policy_id):
query = query.where(models.Project.release_policy_id ==
release_policy_id)
- if is_defined(is_retired):
- query = query.where(models.Project.is_retired == is_retired)
+ if is_defined(status):
+ query = query.where(models.Project.status == status)
if _committee:
query = query.options(select_in_load(models.Project.committee))
@@ -584,7 +584,7 @@ async def create_async_engine(app_config:
type[config.AppConfig]) -> sqlalchemy.
async def get_project_release_policy(data: Session, project_name: str) ->
models.ReleasePolicy | None:
"""Fetch the ReleasePolicy for a project."""
- project = await data.project(name=project_name, is_retired=False,
_release_policy=True).demand(
+ project = await data.project(name=project_name,
status=models.ProjectStatus.ACTIVE, _release_policy=True).demand(
RuntimeError(f"Project {project_name} not found")
)
return project.release_policy
diff --git a/atr/db/models.py b/atr/db/models.py
index 2b0d960..6f43446 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -166,6 +166,13 @@ class Committee(sqlmodel.SQLModel, table=True):
return f"{name} (PPMC)" if self.is_podling else name
+class ProjectStatus(str, enum.Enum):
+ ACTIVE = "active"
+ DORMANT = "dormant"
+ RETIRED = "retired"
+ STANDING = "standing"
+
+
class Project(sqlmodel.SQLModel, table=True):
# TODO: Consider using key or label for primary string keys
# Then we can use simply "name" for full_name, and make it str rather than
str | None
@@ -174,8 +181,7 @@ class Project(sqlmodel.SQLModel, table=True):
# We always include "Apache" in the full_name
full_name: str | None = sqlmodel.Field(default=None)
- # The is_retired flag is not permanent, and is also used for dormant
projects
- is_retired: bool = sqlmodel.Field(default=False)
+ status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE)
super_project_name: str | None = sqlmodel.Field(default=None,
foreign_key="project.name")
# NOTE: Neither "Project" | None nor "Project | None" works
super_project: Optional["Project"] = sqlmodel.Relationship()
diff --git a/atr/routes/committees.py b/atr/routes/committees.py
index 9066f10..a0bb45f 100644
--- a/atr/routes/committees.py
+++ b/atr/routes/committees.py
@@ -35,7 +35,7 @@ async def directory() -> str:
return await template.render(
"committee-directory.html",
committees=committees,
- committee_without_releases=util.committee_without_releases,
+ committee_is_standing=util.committee_is_standing,
)
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index cbec7dd..c6e0a6a 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -443,9 +443,7 @@ async def upload(session: routes.CommitterSession) -> str:
submit = wtforms.SubmitField("Upload KEYS file")
selected_committees = wtforms.SelectMultipleField(
"Associate keys with committees",
- choices=[
- (c.name, c.display_name) for c in user_committees if (not
util.committee_without_releases(c.name))
- ],
+ choices=[(c.name, c.display_name) for c in user_committees if (not
util.committee_is_standing(c.name))],
coerce=str,
option_widget=wtforms.widgets.CheckboxInput(),
widget=wtforms.widgets.ListWidget(prefix_label=False),
diff --git a/atr/routes/preview.py b/atr/routes/preview.py
index 11865e7..6f32dcd 100644
--- a/atr/routes/preview.py
+++ b/atr/routes/preview.py
@@ -111,7 +111,7 @@ async def delete(session: routes.CommitterSession) ->
response.Response:
# Check that the user has access to the project
async with db.session() as data:
- project = await data.project(name=project_name, is_retired=False).get()
+ project = await data.project(name=project_name,
status=models.ProjectStatus.ACTIVE).get()
if not project or not any(
(
(c.name == project.committee_name)
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 87ba850..e13ae04 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -164,7 +164,7 @@ async def delete(session: routes.CommitterSession) ->
response.Response:
async with db.session() as data:
project = await data.project(
- name=project_name, is_retired=False, _releases=True,
_distribution_channels=True
+ name=project_name, status=models.ProjectStatus.ACTIVE,
_releases=True, _distribution_channels=True
).get()
if not project:
@@ -211,7 +211,7 @@ async def select(session: routes.CommitterSession) -> str:
if session.uid:
async with db.session() as data:
# TODO: Move this filtering logic somewhere else
- all_projects = await data.project(is_retired=False,
_committee=True).all()
+ all_projects = await
data.project(status=models.ProjectStatus.ACTIVE, _committee=True).all()
user_projects = [
p
for p in all_projects
@@ -450,7 +450,7 @@ async def _project_add(form: AddFormProtocol, asf_id: str)
-> response.Response:
project = models.Project(
name=label,
full_name=display_name,
- is_retired=False,
+ status=models.ProjectStatus.ACTIVE,
super_project_name=super_project.name if super_project else None,
description=super_project.description if super_project else None,
category=super_project.category if super_project else None,
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 0cbff53..a68a28c 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -75,7 +75,7 @@ async def bulk_status(session: routes.CommitterSession,
task_id: int) -> str | r
async def finished(project_name: str) -> str:
"""View all finished releases for a project."""
async with db.session() as data:
- project = await data.project(name=project_name,
is_retired=False).demand(
+ project = await data.project(name=project_name,
status=models.ProjectStatus.ACTIVE).demand(
base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
)
@@ -126,7 +126,7 @@ async def select(session: routes.CommitterSession,
project_name: str) -> str:
await session.check_access(project_name)
async with db.session() as data:
- project = await data.project(name=project_name, is_retired=False,
_releases=True).demand(
+ project = await data.project(name=project_name,
status=models.ProjectStatus.ACTIVE, _releases=True).demand(
base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
)
releases = await project.releases_in_progress
diff --git a/atr/routes/start.py b/atr/routes/start.py
index 58397fd..b4c4c90 100644
--- a/atr/routes/start.py
+++ b/atr/routes/start.py
@@ -50,7 +50,7 @@ async def create_release_draft(project_name: str, version:
str, asf_uid: str) ->
# Get the project from the project name
async with db.session() as data:
async with data.begin():
- project = await data.project(name=project_name, is_retired=False,
_committee=True).get()
+ project = await data.project(name=project_name,
status=models.ProjectStatus.ACTIVE, _committee=True).get()
if not project:
raise routes.FlashError(f"Project {project_name} not found")
@@ -104,7 +104,7 @@ async def selected(session: routes.CommitterSession,
project_name: str) -> respo
await session.check_access(project_name)
async with db.session() as data:
- project = await data.project(name=project_name,
is_retired=False).demand(
+ project = await data.project(name=project_name,
status=models.ProjectStatus.ACTIVE).demand(
base.ASFQuartException(f"Project {project_name} not found",
errorcode=404)
)
diff --git a/atr/ssh.py b/atr/ssh.py
index a09fcbf..c9853ac 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -284,7 +284,7 @@ async def _step_04_command_validate(
ssh_uid = process.get_extra_info("username")
async with db.session() as data:
- project = await data.project(name=path_project, is_retired=False,
_committee=True).get()
+ project = await data.project(name=path_project,
status=models.ProjectStatus.ACTIVE, _committee=True).get()
if project is None:
# Projects are public, so existence information is public
return _fail(process, f"Project '{path_project}' does not exist",
None)
@@ -540,9 +540,9 @@ async def _step_07c_ensure_release_object_for_write(
name=models.release_name(project_name, version_name),
_committee=True
).get()
if release is None:
- project = await data.project(name=project_name,
is_retired=False, _committee=True).demand(
- RuntimeError("Project not found after validation")
- )
+ project = await data.project(
+ name=project_name, status=models.ProjectStatus.ACTIVE,
_committee=True
+ ).demand(RuntimeError("Project not found after
validation"))
if version_name_error :=
util.version_name_error(version_name):
# This should ideally be caught by path validation,
but double check
raise RuntimeError(f'Invalid version name
"{version_name}": {version_name_error}')
diff --git a/atr/templates/committee-directory.html
b/atr/templates/committee-directory.html
index 2906e86..3a65e94 100644
--- a/atr/templates/committee-directory.html
+++ b/atr/templates/committee-directory.html
@@ -42,6 +42,10 @@
.page-project-subcard-categories {
font-size: 0.8em;
}
+
+ .page-project-inactive {
+ opacity: 0.6;
+ }
</style>
{% endblock stylesheets %}
@@ -117,7 +121,7 @@
{% set max_initial_projects = 2 %}
{% if committee.projects %}
{% for project in committee.projects|sort(attribute="name") %}
- <div class="card mb-3 shadow-sm page-project-subcard {% if
loop.index > max_initial_projects %}page-project-extra d-none{% endif %}"
+ <div class="card mb-3 shadow-sm page-project-subcard {% if
loop.index > max_initial_projects %}page-project-extra d-none{% endif %} {% if
project.status.value.lower() != "active" %}page-project-inactive{% endif %}"
data-project-url="{{ as_url(routes.projects.view,
name=project.name) }}">
<div class="card-body p-3 d-flex flex-column h-100">
<div class="d-flex justify-content-between
align-items-start">
@@ -126,7 +130,9 @@
class="text-decoration-none stretched-link">{{
project.display_name }}</a>
</p>
<div>
- {% if project.is_retired %}<span class="badge
text-bg-secondary ms-1">retired</span>{% endif %}
+ {% if project.status.value.lower() != "active" %}
+ <span class="badge text-bg-secondary ms-1">{{
project.status.value.lower() }}</span>
+ {% endif %}
</div>
</div>
<div class="mb-1 page-project-subcard-categories">
@@ -152,7 +158,7 @@
{# Add an else clause here if we decide to show an alternative
to an empty card #}
{% endif %}
</div>
- {% if current_user and is_part and (not
committee_without_releases(committee.name)) %}
+ {% if current_user and is_part and (not
committee_is_standing(committee.name)) %}
<a href="{{ as_url(routes.projects.add_project,
committee_name=committee.name) }}"
title="Create a project for {{ committee.display_name }}"
class="text-decoration-none d-block mt-4 mb-3">
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 797520a..dd4cc49 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -24,9 +24,9 @@
<div class="col-md">
<h1>{{ project.display_name }}</h1>
</div>
- {% if project.is_retired %}
+ {% if project.status.value.lower() != "active" %}
<div class="col-sm-auto">
- <span class="badge text-bg-secondary">retired</span>
+ <span class="badge text-bg-secondary">{{ project.status.value.lower()
}}</span>
</div>
{% endif %}
</div>
@@ -65,7 +65,7 @@
</div>
</div>
- {% if not project.is_retired %}
+ {% if project.status.value.lower() == "active" %}
<div class="card mb-4">
<div class="card-header bg-light d-flex justify-content-between
align-items-center">
<h3 class="mb-0">Release policy</h3>
diff --git a/atr/templates/projects.html b/atr/templates/projects.html
index 40eb4b5..624efde 100644
--- a/atr/templates/projects.html
+++ b/atr/templates/projects.html
@@ -40,7 +40,7 @@
{% endif %}
{% endif %}
<div class="col">
- <div class="card h-100 shadow-sm atr-cursor-pointer page-project-card
{{ 'bg-body-secondary' if project.is_retired else '' }}"
+ <div class="card h-100 shadow-sm atr-cursor-pointer page-project-card
{{ '' if project.status.value.lower() == 'active' else 'bg-body-secondary' }}"
data-project-url="{{ as_url(routes.projects.view,
name=project.name) }}"
data-is-participant="{{ 'true' if is_part else 'false' }}">
<div class="card-body">
@@ -48,9 +48,9 @@
<div class="col-sm">
<h3 class="card-title fs-4 mb-3">{{ project.display_name
}}</h3>
</div>
- {% if project.is_retired %}
+ {% if project.status.value.lower() != 'active' %}
<div class="col-sm-2">
- <span class="badge text-bg-secondary">retired</span>
+ <span class="badge text-bg-secondary">{{
project.status.value.lower() }}</span>
</div>
{% endif %}
</div>
diff --git a/atr/user.py b/atr/user.py
index 4039b83..cf755bb 100644
--- a/atr/user.py
+++ b/atr/user.py
@@ -63,7 +63,7 @@ async def projects(uid: str, committee_only: bool = False,
super_project: bool =
async with db.session() as data:
# Must have releases, because this is used in candidate_drafts
projects = await data.project(
- is_retired=False, _committee=True, _releases=True,
_super_project=super_project
+ status=models.ProjectStatus.ACTIVE, _committee=True,
_releases=True, _super_project=super_project
).all()
for p in projects:
if p.committee is None:
diff --git a/atr/util.py b/atr/util.py
index 250c60d..98f7923 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -52,13 +52,14 @@ import atr.user as user
F = TypeVar("F", bound="QuartFormTyped")
T = TypeVar("T")
-_COMMITTEES_WITHOUT_RELEASES: Final[set[str]] = {
+_LOGGER: Final = logging.getLogger(__name__)
+# TODO: Move to committee data
+_STANDING_COMMITTEES: Final[set[str]] = {
"attic",
"comdev",
"logodev",
"whimsy",
}
-_LOGGER: Final = logging.getLogger(__name__)
@dataclasses.dataclass
@@ -160,8 +161,8 @@ def chmod_directories(path: pathlib.Path, permissions: int
= 0o755) -> None:
os.chmod(dir_path, permissions)
-def committee_without_releases(committee_name: str) -> bool:
- return committee_name in _COMMITTEES_WITHOUT_RELEASES
+def committee_is_standing(committee_name: str) -> bool:
+ return committee_name in _STANDING_COMMITTEES
def compute_sha3_256(file_data: bytes) -> str:
diff --git a/migrations/versions/0010_2025.06.12_3b27ab22.py
b/migrations/versions/0010_2025.06.12_3b27ab22.py
new file mode 100644
index 0000000..0731b7b
--- /dev/null
+++ b/migrations/versions/0010_2025.06.12_3b27ab22.py
@@ -0,0 +1,36 @@
+"""Add project statuses
+
+Revision ID: 0010_2025.06.12_3b27ab22
+Revises: 0009_2025.06.12_d6037201
+Create Date: 2025-06-12 19:03:06.665183+00:00
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# Revision identifiers, used by Alembic
+revision: str = "0010_2025.06.12_3b27ab22"
+down_revision: str | None = "0009_2025.06.12_d6037201"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("project", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column(
+ "status",
+ sa.Enum("ACTIVE", "DORMANT", "RETIRED", "STANDING",
name="projectstatus"),
+ nullable=False,
+ server_default="ACTIVE",
+ )
+ )
+ batch_op.drop_column("is_retired")
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("project", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("is_retired", sa.BOOLEAN(),
nullable=False))
+ batch_op.drop_column("status")
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]