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]

Reply via email to