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 1682499  Simplify the model
1682499 is described below

commit 1682499c698fc660d50091d03f620ee4b8b7dc42
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Mar 14 19:58:58 2025 +0200

    Simplify the model
---
 atr/blueprints/admin/admin.py                      |  71 +++++----
 .../{update-pmcs.html => update-committees.html}   |   0
 atr/blueprints/api/api.py                          |  40 +++---
 atr/db/__init__.py                                 | 159 +++++++--------------
 atr/db/models.py                                   | 104 ++++++--------
 atr/db/service.py                                  |  28 ++--
 atr/routes/candidate.py                            |  74 +++++-----
 atr/routes/download.py                             |  18 +--
 atr/routes/keys.py                                 |  42 +++---
 atr/routes/package.py                              |  20 +--
 atr/routes/projects.py                             |  34 +++--
 atr/routes/release.py                              |  46 +++---
 atr/static/css/atr.css                             |   7 +-
 atr/static/css/root.css                            |  58 --------
 atr/tasks/mailtest.py                              |  14 +-
 atr/tasks/signature.py                             |  17 ++-
 atr/tasks/vote.py                                  |  20 +--
 atr/templates/candidate-create.html                |  28 ++--
 atr/templates/candidate-review.html                |   6 +-
 atr/templates/keys-add.html                        |  12 +-
 atr/templates/keys-review.html                     |   4 +-
 atr/templates/package-add.html                     |   4 +-
 atr/templates/project-directory.html               |  24 ++--
 atr/templates/project-view.html                    |   7 +-
 atr/templates/release-bulk.html                    |   4 +-
 atr/templates/release-vote.html                    |   6 +-
 docs/plan.html                                     |   2 +-
 docs/plan.md                                       |   2 +-
 28 files changed, 378 insertions(+), 473 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 6607322..5e01f73 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -121,11 +121,10 @@ async def admin_data(model: str = "Committee") -> str:
     """Browse all records in the database."""
     async with db.session() as data:
         # Map of model names to their classes
-        # TODO: Add distribution channel, pmc key link, and any others
+        # TODO: Add distribution channel, key link, and any others
         model_methods: dict[str, Callable[[], db.Query[Any]]] = {
             "Committee": data.committee,
             "Package": data.package,
-            "Product": data.product,
             "Project": data.project,
             "PublicSigningKey": data.public_signing_key,
             "Release": data.release,
@@ -165,7 +164,7 @@ async def admin_projects_update() -> str | 
response.Response | tuple[Mapping[str
     """Update projects from remote data."""
     if quart.request.method == "POST":
         try:
-            updated_count = await _update_pmcs()
+            updated_count = await _update_committees()
             return {
                 "message": f"Successfully updated {updated_count} projects 
(PMCs and PPMCs) with membership data",
                 "category": "success",
@@ -182,10 +181,10 @@ async def admin_projects_update() -> str | 
response.Response | tuple[Mapping[str
             }, 200
 
     # For GET requests, show the update form
-    return await quart.render_template("update-pmcs.html")
+    return await quart.render_template("update-committees.html")
 
 
-async def _update_pmcs() -> int:
+async def _update_committees() -> int:
     ldap_projects = await apache.get_ldap_projects_data()
     podlings_data = await apache.get_current_podlings_data()
     groups_data = await apache.get_groups_data()
@@ -198,70 +197,70 @@ async def _update_pmcs() -> int:
             for project in ldap_projects.projects:
                 name = project.name
                 # Skip non-PMC committees
-                if not project.pmc:
+                if project.pmc is None:
                     continue
 
                 # Get or create PMC
-                pmc = await data.committee(name=name).get()
-                if not pmc:
-                    pmc = models.PMC(name=name)
-                    data.add(pmc)
-                    pmc_core_project = models.Project(name=name, pmc=pmc)
-                    data.add(pmc_core_project)
+                committee = await data.committee(name=name).get()
+                if not committee:
+                    committee = models.Committee(name=name)
+                    data.add(committee)
+                    committee_core_project = models.Project(name=name, 
committee=committee)
+                    data.add(committee_core_project)
 
                 # Update PMC data from groups.json
                 pmc_members = groups_data.get(f"{name}-pmc")
                 committers = groups_data.get(name)
-                pmc.pmc_members = pmc_members if pmc_members is not None else 
[]
-                pmc.committers = committers if committers is not None else []
+                committee.committee_members = pmc_members if pmc_members is 
not None else []
+                committee.committers = committers if committers is not None 
else []
                 # Ensure this is set for PMCs
-                pmc.is_podling = False
+                committee.is_podling = False
 
                 # For release managers, use PMC members for now
                 # TODO: Consider a more sophisticated way to determine release 
managers
                 #       from my POV, the list of release managers should be 
the list of people
                 #       that have actually cut a release for that project
-                pmc.release_managers = pmc.pmc_members
+                committee.release_managers = committee.committee_members
 
                 updated_count += 1
 
             # Then add PPMCs (podlings)
             for podling_name, podling_data in podlings_data:
                 # Get or create PPMC
-                ppmc = await data.committee(name=podling_name).get()
-                if not ppmc:
-                    ppmc = models.PMC(name=podling_name, is_podling=True)
-                    data.add(ppmc)
-                    ppmc_core_project = models.Project(name=podling_name, 
is_podling=True, pmc=ppmc)
-                    data.add(ppmc_core_project)
+                podling = await data.committee(name=podling_name).get()
+                if not podling:
+                    podling = models.Committee(name=podling_name, 
is_podling=True)
+                    data.add(podling)
+                    podling_core_project = models.Project(name=podling_name, 
committee=podling)
+                    data.add(podling_core_project)
 
                 # Update PPMC data from groups.json
-                ppmc.is_podling = True
+                podling.is_podling = True
                 pmc_members = groups_data.get(f"{podling_name}-pmc")
                 committers = groups_data.get(podling_name)
-                ppmc.pmc_members = pmc_members if pmc_members is not None else 
[]
-                ppmc.committers = committers if committers is not None else []
+                podling.committee_members = pmc_members if pmc_members is not 
None else []
+                podling.committers = committers if committers is not None else 
[]
                 # Use PPMC members as release managers
-                ppmc.release_managers = ppmc.pmc_members
+                podling.release_managers = podling.committee_members
 
                 updated_count += 1
 
             # Add special entry for Tooling PMC
             # Not clear why, but it's not in the Whimsy data
-            tooling_pmc = await data.committee(name="tooling").get()
-            if not tooling_pmc:
-                tooling_pmc = models.PMC(name="tooling")
-                data.add(tooling_pmc)
-                tooling_project = models.Project(name="tooling", 
pmc=tooling_pmc)
+            tooling_committee = await data.committee(name="tooling").get()
+            if not tooling_committee:
+                tooling_committee = models.Committee(name="tooling")
+                data.add(tooling_committee)
+                tooling_project = models.Project(name="tooling", 
committee=tooling_committee)
                 data.add(tooling_project)
                 updated_count += 1
 
             # Update Tooling PMC data
-            # Could put this in the "if not tooling_pmc" block, perhaps
-            tooling_pmc.pmc_members = ["wave", "tn", "sbp"]
-            tooling_pmc.committers = ["wave", "tn", "sbp"]
-            tooling_pmc.release_managers = ["wave"]
-            tooling_pmc.is_podling = False
+            # Could put this in the "if not tooling_committee" block, perhaps
+            tooling_committee.committee_members = ["wave", "tn", "sbp"]
+            tooling_committee.committers = ["wave", "tn", "sbp"]
+            tooling_committee.release_managers = ["wave"]
+            tooling_committee.is_podling = False
 
     return updated_count
 
diff --git a/atr/blueprints/admin/templates/update-pmcs.html 
b/atr/blueprints/admin/templates/update-committees.html
similarity index 100%
rename from atr/blueprints/admin/templates/update-pmcs.html
rename to atr/blueprints/admin/templates/update-committees.html
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 2038569..e93f887 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -15,16 +15,17 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import dataclasses
 from collections.abc import Mapping
-from dataclasses import dataclass
 
-from quart import Response, jsonify
-from quart_schema import validate_querystring, validate_response
-from werkzeug.exceptions import NotFound
+import quart
+import quart_schema
+import werkzeug.exceptions as exceptions
 
 import atr.blueprints.api as api
-from atr.db.models import PMC
-from atr.db.service import get_pmc_by_name, get_pmcs, get_tasks
+import atr.db as db
+import atr.db.models as models
+import atr.db.service as service
 
 # FIXME: we need to return the dumped model instead of the actual pydantic 
class
 #        as otherwise pyright will complain about the return type
@@ -33,32 +34,31 @@ from atr.db.service import get_pmc_by_name, get_pmcs, 
get_tasks
 
 
 @api.BLUEPRINT.route("/projects/<name>")
-@validate_response(PMC, 200)
+@quart_schema.validate_response(models.Committee, 200)
 async def project_by_name(name: str) -> tuple[Mapping, int]:
-    pmc = await get_pmc_by_name(name)
-    if pmc:
-        return pmc.model_dump(), 200
-    else:
-        raise NotFound()
+    async with db.session() as data:
+        committee = await 
data.committee(name=name).demand(exceptions.NotFound())
+        return committee.model_dump(), 200
 
 
 @api.BLUEPRINT.route("/projects")
-@validate_response(list[PMC], 200)
+@quart_schema.validate_response(list[models.Committee], 200)
 async def projects() -> tuple[list[Mapping], int]:
     """List all projects in the database."""
-    pmcs = await get_pmcs()
-    return [pmc.model_dump() for pmc in pmcs], 200
+    async with db.session() as data:
+        committees = await data.committee().all()
+        return [committee.model_dump() for committee in committees], 200
 
 
-@dataclass
[email protected]
 class Pagination:
     offset: int = 0
     limit: int = 20
 
 
 @api.BLUEPRINT.route("/tasks")
-@validate_querystring(Pagination)
-async def api_tasks(query_args: Pagination) -> Response:
-    paged_tasks, count = await get_tasks(limit=query_args.limit, 
offset=query_args.offset)
+@quart_schema.validate_querystring(Pagination)
+async def api_tasks(query_args: Pagination) -> quart.Response:
+    paged_tasks, count = await service.get_tasks(limit=query_args.limit, 
offset=query_args.offset)
     result = {"data": [x.model_dump(exclude={"result"}) for x in paged_tasks], 
"count": count}
-    return jsonify(result)
+    return quart.jsonify(result)
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index 70a09d5..22d133b 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -85,44 +85,44 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         name: Any = _DEFAULT,
         full_name: Any = _DEFAULT,
         is_podling: Any = _DEFAULT,
-        parent_pmc_id: Any = _DEFAULT,
-        pmc_members: Any = _DEFAULT,
+        parent_committee_id: Any = _DEFAULT,
+        committee_members: Any = _DEFAULT,
         committers: Any = _DEFAULT,
         release_managers: Any = _DEFAULT,
         vote_policy_id: Any = _DEFAULT,
         name_in: list[str] | _DefaultArgument = _DEFAULT,
         _public_signing_keys: bool = False,
         _vote_policy: bool = False,
-    ) -> Query[models.PMC]:
-        query = sqlmodel.select(models.PMC)
+    ) -> Query[models.Committee]:
+        query = sqlmodel.select(models.Committee)
 
         if id is not _DEFAULT:
-            query = query.where(models.PMC.id == id)
+            query = query.where(models.Committee.id == id)
         if name is not _DEFAULT:
-            query = query.where(models.PMC.name == name)
+            query = query.where(models.Committee.name == name)
         if full_name is not _DEFAULT:
-            query = query.where(models.PMC.full_name == full_name)
+            query = query.where(models.Committee.full_name == full_name)
         if is_podling is not _DEFAULT:
-            query = query.where(models.PMC.is_podling == is_podling)
-        if parent_pmc_id is not _DEFAULT:
-            query = query.where(models.PMC.parent_pmc_id == parent_pmc_id)
-        if pmc_members is not _DEFAULT:
-            query = query.where(models.PMC.pmc_members == pmc_members)
+            query = query.where(models.Committee.is_podling == is_podling)
+        if parent_committee_id is not _DEFAULT:
+            query = query.where(models.Committee.parent_committee_id == 
parent_committee_id)
+        if committee_members is not _DEFAULT:
+            query = query.where(models.Committee.committee_members == 
committee_members)
         if committers is not _DEFAULT:
-            query = query.where(models.PMC.committers == committers)
+            query = query.where(models.Committee.committers == committers)
         if release_managers is not _DEFAULT:
-            query = query.where(models.PMC.release_managers == 
release_managers)
+            query = query.where(models.Committee.release_managers == 
release_managers)
         if vote_policy_id is not _DEFAULT:
-            query = query.where(models.PMC.vote_policy_id == vote_policy_id)
+            query = query.where(models.Committee.vote_policy_id == 
vote_policy_id)
 
         if not isinstance(name_in, _DefaultArgument):
-            models_pmc_name = validate_instrumented_attribute(models.PMC.name)
-            query = query.where(models_pmc_name.in_(name_in))
+            models_committee_name = 
validate_instrumented_attribute(models.Committee.name)
+            query = query.where(models_committee_name.in_(name_in))
 
         if _public_signing_keys:
-            query = 
query.options(select_in_load(models.PMC.public_signing_keys))
+            query = 
query.options(select_in_load(models.Committee.public_signing_keys))
         if _vote_policy:
-            query = query.options(select_in_load(models.PMC.vote_policy))
+            query = query.options(select_in_load(models.Committee.vote_policy))
 
         return Query(self, query)
 
@@ -138,8 +138,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         release_key: Any = _DEFAULT,
         _release: bool = False,
         _tasks: bool = False,
-        _release_product: bool = False,
-        _release_pmc: bool = False,
+        _release_project: bool = False,
+        _release_committee: bool = False,
     ) -> Query[models.Package]:
         query = sqlmodel.select(models.Package)
 
@@ -163,73 +163,27 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.options(select_in_load(models.Package.release))
         if _tasks:
             query = query.options(select_in_load(models.Package.tasks))
-        if _release_product:
-            query = query.options(select_in_load(models.Package.release, 
models.Release.product))
-        if _release_pmc:
+        if _release_project:
+            query = query.options(select_in_load(models.Package.release, 
models.Release.project))
+        if _release_committee:
             query = query.options(
-                select_in_load_nested(
-                    models.Package.release, models.Release.product, 
models.Product.project, models.Project.pmc
-                )
+                select_in_load_nested(models.Package.release, 
models.Release.project, models.Project.committee)
             )
         return Query(self, query)
 
-    def product(
-        self,
-        id: Any = _DEFAULT,
-        project_id: Any = _DEFAULT,
-        product_name: Any = _DEFAULT,
-        latest_version: Any = _DEFAULT,
-        vote_policy_id: Any = _DEFAULT,
-        project_pmc_id: Any = _DEFAULT,
-        _project: bool = False,
-        _distribution_channels: bool = False,
-        _vote_policy: bool = False,
-        _releases: bool = False,
-        _project_pmc: bool = False,
-    ) -> Query[models.Product]:
-        query = sqlmodel.select(models.Product)
-
-        if id is not _DEFAULT:
-            query = query.where(models.Product.id == id)
-        if project_id is not _DEFAULT:
-            query = query.where(models.Product.project_id == project_id)
-        if product_name is not _DEFAULT:
-            query = query.where(models.Product.product_name == product_name)
-        if latest_version is not _DEFAULT:
-            query = query.where(models.Product.latest_version == 
latest_version)
-        if vote_policy_id is not _DEFAULT:
-            query = query.where(models.Product.vote_policy_id == 
vote_policy_id)
-
-        if project_pmc_id is not _DEFAULT:
-            query = query.join(
-                models.Project, 
validate_instrumented_attribute(models.Product.project_id) == models.Project.id
-            ).where(models.Project.pmc_id == project_pmc_id)
-
-        if _project:
-            query = query.options(select_in_load(models.Product.project))
-        if _distribution_channels:
-            query = 
query.options(select_in_load(models.Product.distribution_channels))
-        if _vote_policy:
-            query = query.options(select_in_load(models.Product.vote_policy))
-        if _releases:
-            query = query.options(select_in_load(models.Product.releases))
-        if _project_pmc:
-            query = 
query.options(select_in_load_nested(models.Product.project, models.Project.pmc))
-
-        return Query(self, query)
-
     def project(
         self,
         id: Any = _DEFAULT,
         name: Any = _DEFAULT,
         full_name: Any = _DEFAULT,
         is_podling: Any = _DEFAULT,
-        pmc_id: Any = _DEFAULT,
+        committee_id: Any = _DEFAULT,
         vote_policy_id: Any = _DEFAULT,
-        _pmc: bool = False,
-        _products: bool = False,
+        _committee: bool = False,
+        _releases: bool = False,
+        _distribution_channels: bool = False,
         _vote_policy: bool = False,
-        _pmc_public_signing_keys: bool = False,
+        _committee_public_signing_keys: bool = False,
     ) -> Query[models.Project]:
         query = sqlmodel.select(models.Project)
 
@@ -241,19 +195,21 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.where(models.Project.full_name == full_name)
         if is_podling is not _DEFAULT:
             query = query.where(models.Project.is_podling == is_podling)
-        if pmc_id is not _DEFAULT:
-            query = query.where(models.Project.pmc_id == pmc_id)
+        if committee_id is not _DEFAULT:
+            query = query.where(models.Project.committee_id == committee_id)
         if vote_policy_id is not _DEFAULT:
             query = query.where(models.Project.vote_policy_id == 
vote_policy_id)
 
-        if _pmc:
-            query = query.options(select_in_load(models.Project.pmc))
-        if _products:
-            query = query.options(select_in_load(models.Project.products))
+        if _committee:
+            query = query.options(select_in_load(models.Project.committee))
+        if _releases:
+            query = query.options(select_in_load(models.Project.releases))
+        if _distribution_channels:
+            query = 
query.options(select_in_load(models.Project.distribution_channels))
         if _vote_policy:
             query = query.options(select_in_load(models.Project.vote_policy))
-        if _pmc_public_signing_keys:
-            query = query.options(select_in_load_nested(models.Project.pmc, 
models.PMC.public_signing_keys))
+        if _committee_public_signing_keys:
+            query = 
query.options(select_in_load_nested(models.Project.committee, 
models.Committee.public_signing_keys))
 
         return Query(self, query)
 
@@ -267,7 +223,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         declared_uid: Any = _DEFAULT,
         apache_uid: Any = _DEFAULT,
         ascii_armored_key: Any = _DEFAULT,
-        _pmcs: bool = False,
+        _committees: bool = False,
     ) -> Query[models.PublicSigningKey]:
         query = sqlmodel.select(models.PublicSigningKey)
 
@@ -288,8 +244,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         if ascii_armored_key is not _DEFAULT:
             query = query.where(models.PublicSigningKey.ascii_armored_key == 
ascii_armored_key)
 
-        if _pmcs:
-            query = query.options(select_in_load(models.PublicSigningKey.pmcs))
+        if _committees:
+            query = 
query.options(select_in_load(models.PublicSigningKey.committees))
 
         return Query(self, query)
 
@@ -299,16 +255,16 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         stage: Any = _DEFAULT,
         phase: Any = _DEFAULT,
         created: Any = _DEFAULT,
-        product_id: Any = _DEFAULT,
+        project_id: Any = _DEFAULT,
         package_managers: Any = _DEFAULT,
         version: Any = _DEFAULT,
         sboms: Any = _DEFAULT,
         vote_policy_id: Any = _DEFAULT,
         votes: Any = _DEFAULT,
-        _product: bool = False,
+        _project: bool = False,
         _packages: bool = False,
         _vote_policy: bool = False,
-        _pmc: bool = False,
+        _committee: bool = False,
         _packages_tasks: bool = False,
     ) -> Query[models.Release]:
         query = sqlmodel.select(models.Release)
@@ -321,8 +277,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
             query = query.where(models.Release.phase == phase)
         if created is not _DEFAULT:
             query = query.where(models.Release.created == created)
-        if product_id is not _DEFAULT:
-            query = query.where(models.Release.product_id == product_id)
+        if project_id is not _DEFAULT:
+            query = query.where(models.Release.project_id == project_id)
         if package_managers is not _DEFAULT:
             query = query.where(models.Release.package_managers == 
package_managers)
         if version is not _DEFAULT:
@@ -334,16 +290,14 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         if votes is not _DEFAULT:
             query = query.where(models.Release.votes == votes)
 
-        if _product:
-            query = query.options(select_in_load(models.Release.product))
+        if _project:
+            query = query.options(select_in_load(models.Release.project))
         if _packages:
             query = query.options(select_in_load(models.Release.packages))
         if _vote_policy:
             query = query.options(select_in_load(models.Release.vote_policy))
-        if _pmc:
-            query = query.options(
-                select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc)
-            )
+        if _committee:
+            query = 
query.options(select_in_load_nested(models.Release.project, 
models.Project.committee))
         if _packages_tasks:
             query = 
query.options(select_in_load_nested(models.Release.packages, 
models.Package.tasks))
 
@@ -405,9 +359,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         min_hours: Any = _DEFAULT,
         release_checklist: Any = _DEFAULT,
         pause_for_rm: Any = _DEFAULT,
-        _pmcs: bool = False,
+        _committees: bool = False,
         _projects: bool = False,
-        _products: bool = False,
         _releases: bool = False,
     ) -> Query[models.VotePolicy]:
         query = sqlmodel.select(models.VotePolicy)
@@ -425,12 +378,10 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
         if pause_for_rm is not _DEFAULT:
             query = query.where(models.VotePolicy.pause_for_rm == pause_for_rm)
 
-        if _pmcs:
-            query = query.options(select_in_load(models.VotePolicy.pmcs))
+        if _committees:
+            query = query.options(select_in_load(models.VotePolicy.committees))
         if _projects:
             query = query.options(select_in_load(models.VotePolicy.projects))
-        if _products:
-            query = query.options(select_in_load(models.VotePolicy.products))
         if _releases:
             query = query.options(select_in_load(models.VotePolicy.releases))
 
diff --git a/atr/db/models.py b/atr/db/models.py
index 917d9d0..1776156 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -29,7 +29,7 @@ import sqlmodel
 
 
 class UserRole(str, enum.Enum):
-    PMC_MEMBER = "pmc_member"
+    COMMITTEE_MEMBER = "committee_member"
     RELEASE_MANAGER = "release_manager"
     COMMITTER = "committer"
     VISITOR = "visitor"
@@ -37,8 +37,8 @@ class UserRole(str, enum.Enum):
     SYSADMIN = "sysadmin"
 
 
-class PMCKeyLink(sqlmodel.SQLModel, table=True):
-    pmc_id: int = sqlmodel.Field(foreign_key="pmc.id", primary_key=True)
+class KeyLink(sqlmodel.SQLModel, table=True):
+    committee_id: int = sqlmodel.Field(foreign_key="committee.id", 
primary_key=True)
     key_fingerprint: str = 
sqlmodel.Field(foreign_key="publicsigningkey.fingerprint", primary_key=True)
 
 
@@ -59,8 +59,8 @@ class PublicSigningKey(sqlmodel.SQLModel, table=True):
     apache_uid: str
     # The ASCII armored key
     ascii_armored_key: str
-    # The PMCs that use this key
-    pmcs: list["PMC"] = 
sqlmodel.Relationship(back_populates="public_signing_keys", 
link_model=PMCKeyLink)
+    # The committees that use this key
+    committees: list["Committee"] = 
sqlmodel.Relationship(back_populates="public_signing_keys", link_model=KeyLink)
 
 
 class VotePolicy(sqlmodel.SQLModel, table=True):
@@ -71,65 +71,67 @@ class VotePolicy(sqlmodel.SQLModel, table=True):
     release_checklist: str = sqlmodel.Field(default="")
     pause_for_rm: bool = sqlmodel.Field(default=False)
 
-    # One-to-many: A vote policy can be used by multiple PMCs
-    pmcs: list["PMC"] = sqlmodel.Relationship(back_populates="vote_policy")
+    # One-to-many: A vote policy can be used by multiple committees
+    committees: list["Committee"] = 
sqlmodel.Relationship(back_populates="vote_policy")
     # One-to-many: A vote policy can be used by multiple projects
     projects: list["Project"] = 
sqlmodel.Relationship(back_populates="vote_policy")
-    # One-to-many: A vote policy can be used by multiple product lines
-    products: list["Product"] = 
sqlmodel.Relationship(back_populates="vote_policy")
     # One-to-many: A vote policy can be used by multiple releases
     releases: list["Release"] = 
sqlmodel.Relationship(back_populates="vote_policy")
 
 
-class PMC(sqlmodel.SQLModel, table=True):
+class Committee(sqlmodel.SQLModel, table=True):
     id: int = sqlmodel.Field(default=None, primary_key=True)
     name: str = sqlmodel.Field(unique=True)
     full_name: str | None = sqlmodel.Field(default=None)
     # True only if this is an incubator podling with a PPMC
     is_podling: bool = sqlmodel.Field(default=False)
 
-    # One-to-many: A PMC can have multiple child PMCs, each child PMC belongs 
to one parent PMC
-    child_pmcs: list["PMC"] = sqlmodel.Relationship(
+    # One-to-many: A committee can have multiple child committees, each child 
committee belongs to one parent committee
+    child_committees: list["Committee"] = sqlmodel.Relationship(
         sa_relationship_kwargs=dict(
-            backref=sqlalchemy.orm.backref("parent_pmc", remote_side="PMC.id"),
+            backref=sqlalchemy.orm.backref("parent_committee", 
remote_side="Committee.id"),
         ),
     )
-    parent_pmc_id: int | None = sqlmodel.Field(default=None, 
foreign_key="pmc.id")
-    # One-to-many: A PMC can have multiple projects, each project belongs to 
one PMC
-    projects: list["Project"] = sqlmodel.Relationship(back_populates="pmc")
+    parent_committee_id: int | None = sqlmodel.Field(default=None, 
foreign_key="committee.id")
+    # One-to-many: A committee can have multiple projects, each project 
belongs to one committee
+    projects: list["Project"] = 
sqlmodel.Relationship(back_populates="committee")
 
-    pmc_members: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+    committee_members: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
     committers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
     release_managers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
 
-    # Many-to-many: A PMC can have multiple signing keys, and a signing key 
can belong to multiple PMCs
-    public_signing_keys: list[PublicSigningKey] = 
sqlmodel.Relationship(back_populates="pmcs", link_model=PMCKeyLink)
+    # Many-to-many: A committee can have multiple signing keys, and a signing 
key can belong to multiple committees
+    public_signing_keys: list[PublicSigningKey] = 
sqlmodel.Relationship(back_populates="committees", link_model=KeyLink)
 
-    # Many-to-one: A PMC can have one vote policy, a vote policy can be used 
by multiple entities
+    # Many-to-one: A committee can have one vote policy, a vote policy can be 
used by multiple entities
     vote_policy_id: int | None = sqlmodel.Field(default=None, 
foreign_key="votepolicy.id")
-    vote_policy: VotePolicy | None = 
sqlmodel.Relationship(back_populates="pmcs")
+    vote_policy: VotePolicy | None = 
sqlmodel.Relationship(back_populates="committees")
 
     @property
     def display_name(self) -> str:
-        """Get the display name for the PMC/PPMC."""
+        """Get the display name for the committee."""
         name = self.name if self.full_name is None else self.full_name
-        return f"{name} (PPMC)" if self.is_podling else name
+        return f"{name} (podling)" if self.is_podling else name
 
 
 class Project(sqlmodel.SQLModel, table=True):
-    id: int | None = sqlmodel.Field(default=None, primary_key=True)
+    id: int = sqlmodel.Field(default=None, primary_key=True)
     name: str = sqlmodel.Field(unique=True)
     full_name: str | None = sqlmodel.Field(default=None)
 
-    # True if this a podling PPMC
+    # True if this a podling project
+    # TODO: We should have this on Committee too, or instead
     is_podling: bool = sqlmodel.Field(default=False)
 
-    # Many-to-one: A product line belongs to one PMC, a PMC can have multiple 
product lines
-    pmc_id: int | None = sqlmodel.Field(default=None, foreign_key="pmc.id")
-    pmc: PMC | None = sqlmodel.Relationship(back_populates="projects")
+    # Many-to-one: A project belongs to one committee, a committee can have 
multiple projects
+    committee_id: int | None = sqlmodel.Field(default=None, 
foreign_key="committee.id")
+    committee: Committee | None = 
sqlmodel.Relationship(back_populates="projects")
+
+    # One-to-many: A project can have multiple releases, each release belongs 
to one project
+    releases: list["Release"] = sqlmodel.Relationship(back_populates="project")
 
-    # One-to-many: A PMC can have multiple product lines, each product line 
belongs to one PMC
-    products: list["Product"] = sqlmodel.Relationship(back_populates="project")
+    # One-to-many: A project can have multiple distribution channels, each 
channel belongs to one project
+    distribution_channels: list["DistributionChannel"] = 
sqlmodel.Relationship(back_populates="project")
 
     # Many-to-one: A Project can have one vote policy, a vote policy can be 
used by multiple entities
     vote_policy_id: int | None = sqlmodel.Field(default=None, 
foreign_key="votepolicy.id")
@@ -142,28 +144,6 @@ class Project(sqlmodel.SQLModel, table=True):
         return f"{name} (podling)" if self.is_podling else name
 
 
-class Product(sqlmodel.SQLModel, table=True):
-    id: int = sqlmodel.Field(default=None, primary_key=True)
-
-    # Many-to-one: A product line belongs to one project, a project can have 
multiple product lines
-    project_id: int | None = sqlmodel.Field(default=None, 
foreign_key="project.id")
-    project: Project | None = sqlmodel.Relationship(back_populates="products")
-
-    product_name: str
-    # TODO: This could be computed dynamically from 
Product.releases[-1].version
-    latest_version: str
-
-    # One-to-many: A product line can have multiple distribution channels, 
each channel belongs to one product line
-    distribution_channels: list["DistributionChannel"] = 
sqlmodel.Relationship(back_populates="product")
-
-    # Many-to-one: A product line can have one vote policy, a vote policy can 
be used by multiple entities
-    vote_policy_id: int | None = sqlmodel.Field(default=None, 
foreign_key="votepolicy.id")
-    vote_policy: VotePolicy | None = 
sqlmodel.Relationship(back_populates="products")
-
-    # One-to-many: A product line can have multiple releases, each release 
belongs to one product line
-    releases: list["Release"] = sqlmodel.Relationship(back_populates="product")
-
-
 class DistributionChannel(sqlmodel.SQLModel, table=True):
     id: int = sqlmodel.Field(default=None, primary_key=True)
     name: str = sqlmodel.Field(index=True, unique=True)
@@ -172,9 +152,9 @@ class DistributionChannel(sqlmodel.SQLModel, table=True):
     is_test: bool = sqlmodel.Field(default=False)
     automation_endpoint: str
 
-    # Many-to-one: A distribution channel belongs to one product line, a 
product line can have multiple channels
-    product_id: int = sqlmodel.Field(foreign_key="product.id")
-    product: Product = 
sqlmodel.Relationship(back_populates="distribution_channels")
+    # Many-to-one: A distribution channel belongs to one project, a project 
can have multiple channels
+    project_id: int = sqlmodel.Field(foreign_key="project.id")
+    project: Project = 
sqlmodel.Relationship(back_populates="distribution_channels")
 
 
 class Package(sqlmodel.SQLModel, table=True):
@@ -293,9 +273,9 @@ class Release(sqlmodel.SQLModel, table=True):
     phase: ReleasePhase
     created: datetime.datetime
 
-    # Many-to-one: A release belongs to one product line, a product line can 
have multiple releases
-    product_id: int = sqlmodel.Field(foreign_key="product.id")
-    product: Product = sqlmodel.Relationship(back_populates="releases")
+    # Many-to-one: A release belongs to one project, a project can have 
multiple releases
+    project_id: int = sqlmodel.Field(foreign_key="project.id")
+    project: Project = sqlmodel.Relationship(back_populates="releases")
 
     package_managers: list[str] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
     # TODO: Not all releases have a version
@@ -314,9 +294,9 @@ class Release(sqlmodel.SQLModel, table=True):
     votes: list[VoteEntry] = sqlmodel.Field(default_factory=list, 
sa_column=sqlalchemy.Column(sqlalchemy.JSON))
 
     @property
-    def pmc(self) -> PMC | None:
-        """Get the PMC for the release."""
-        project = self.product.project
+    def committee(self) -> Committee | None:
+        """Get the committee for the release."""
+        project = self.project
         if project is None:
             return None
-        return project.pmc
+        return project.committee
diff --git a/atr/db/service.py b/atr/db/service.py
index 9a50fba..e97eb06 100644
--- a/atr/db/service.py
+++ b/atr/db/service.py
@@ -26,21 +26,23 @@ import atr.db as db
 import atr.db.models as models
 
 
-async def get_pmc_by_name(name: str, session: 
sqlalchemy.ext.asyncio.AsyncSession | None = None) -> models.PMC | None:
-    """Returns a PMC object by name."""
+async def get_committee_by_name(
+    name: str, session: sqlalchemy.ext.asyncio.AsyncSession | None = None
+) -> models.Committee | None:
+    """Returns a Committee object by name."""
     async with db.create_async_db_session() if session is None else 
contextlib.nullcontext(session) as db_session:
-        statement = sqlmodel.select(models.PMC).where(models.PMC.name == name)
-        pmc = (await db_session.execute(statement)).scalar_one_or_none()
-        return pmc
+        statement = 
sqlmodel.select(models.Committee).where(models.Committee.name == name)
+        committee = (await db_session.execute(statement)).scalar_one_or_none()
+        return committee
 
 
-async def get_pmcs(session: sqlalchemy.ext.asyncio.AsyncSession | None = None) 
-> Sequence[models.PMC]:
-    """Returns a list of PMC objects."""
+async def get_committees(session: sqlalchemy.ext.asyncio.AsyncSession | None = 
None) -> Sequence[models.Committee]:
+    """Returns a list of Committee objects."""
     async with db.create_async_db_session() if session is None else 
contextlib.nullcontext(session) as db_session:
-        # Get all PMCs and their latest releases
-        statement = sqlmodel.select(models.PMC).order_by(models.PMC.name)
-        pmcs = (await db_session.execute(statement)).scalars().all()
-        return pmcs
+        # Get all Committees
+        statement = 
sqlmodel.select(models.Committee).order_by(models.Committee.name)
+        committees = (await db_session.execute(statement)).scalars().all()
+        return committees
 
 
 async def get_release_by_key(storage_key: str) -> models.Release | None:
@@ -50,7 +52,7 @@ async def get_release_by_key(storage_key: str) -> 
models.Release | None:
         query = (
             sqlmodel.select(models.Release)
             .where(models.Release.storage_key == storage_key)
-            .options(db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc))
+            .options(db.select_in_load_nested(models.Release.project, 
models.Project.committee))
         )
         result = await db_session.execute(query)
         return result.scalar_one_or_none()
@@ -63,7 +65,7 @@ def get_release_by_key_sync(storage_key: str) -> 
models.Release | None:
         query = (
             sqlmodel.select(models.Release)
             .where(models.Release.storage_key == storage_key)
-            .options(db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc))
+            .options(db.select_in_load_nested(models.Release.project, 
models.Project.committee))
         )
         result = session.execute(query)
         return result.scalar_one_or_none()
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index cd74caf..d426f47 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -35,15 +35,17 @@ if asfquart.APP is ...:
     raise RuntimeError("APP is not set")
 
 
-def format_artifact_name(project_name: str, product_name: str, version: str, 
is_podling: bool = False) -> str:
+def format_artifact_name(project_name: str, version: str, is_podling: bool = 
False) -> str:
     """Format an artifact name according to Apache naming conventions.
 
-    For regular projects: apache-${project}-${product}-${version}
-    For podlings: apache-${project}-incubating-${product}-${version}
+    For regular projects: apache-${project}-${version}
+    For podlings: apache-${project}-incubating-${version}
     """
+    # TODO: Format this better based on committee and project
+    # Must depend on whether project is a subproject or not
     if is_podling:
-        return f"apache-{project_name}-incubating-{product_name}-{version}"
-    return f"apache-{project_name}-{product_name}-{version}"
+        return f"apache-{project_name}-incubating-{version}"
+    return f"apache-{project_name}-{version}"
 
 
 # Release functions
@@ -53,57 +55,55 @@ async def release_add_post(session: session.ClientSession, 
request: quart.Reques
     """Handle POST request for creating a new release."""
     form = await routes.get_form(request)
 
-    project_name = form.get("project_name")
-    if not project_name:
-        raise base.ASFQuartException("Project name is required", errorcode=400)
-
-    product_name = form.get("product_name")
-    if not product_name:
-        raise base.ASFQuartException("Product name is required", errorcode=400)
+    committee_name = form.get("committee_name")
+    if not committee_name:
+        raise base.ASFQuartException("Committee name is required", 
errorcode=400)
 
     version = form.get("version")
     if not version:
         raise base.ASFQuartException("Version is required", errorcode=400)
 
-    # TODO: Forbid creating a release with an existing project, product, and 
version
+    project_name = form.get("project_name")
+    if not project_name:
+        raise base.ASFQuartException("Project name is required", errorcode=400)
+
+    # TODO: Forbid creating a release with an existing project and version
     # Create the release record in the database
     async with db.session() as data:
         async with data.begin():
-            pmc = await data.committee(name=project_name).get()
-            if not pmc:
-                asfquart.APP.logger.error(f"PMC not found for project 
{project_name}")
-                raise base.ASFQuartException("Project not found", 
errorcode=404)
+            committee = await data.committee(name=committee_name).get()
+            if not committee:
+                asfquart.APP.logger.error(f"Committee not found for project 
{committee_name}")
+                raise base.ASFQuartException("Committee not found", 
errorcode=404)
 
             # Verify user is a PMC member or committer of the project
-            # We use pmc.display_name, so this must come within the transaction
-            if pmc.name not in (session.committees + session.projects):
+            # We use committee.name, so this must come within the transaction
+            if committee.name not in (session.committees + session.projects):
                 raise base.ASFQuartException(
-                    f"You must be a PMC member or committer of 
{pmc.display_name} to submit a release candidate",
+                    f"You must be a PMC member or committer of 
{committee.display_name} to submit a release candidate",
                     errorcode=403,
                 )
 
             # Generate a 128-bit random token for the release storage key
             # TODO: Perhaps we should call this the release_key instead
             storage_key = secrets.token_hex(16)
-
-            # Create or get existing product line
-            product = await data.product(product_name=product_name, 
project_pmc_id=pmc.id).get()
-            if not product:
-                # Create new product line if it doesn't exist
-                project = await data.project(name=project_name, 
pmc_id=pmc.id).demand(
-                    base.ASFQuartException(f"Project {project_name} not 
found", errorcode=404)
+            project = await data.project(name=project_name).get()
+            if not project:
+                # Create a new project record
+                project = models.Project(
+                    name=project_name,
+                    committee_id=committee.id,
                 )
-                product = models.Product(project_id=project.id, 
product_name=product_name, latest_version=version)
-                data.add(product)
-                # Flush to make the product.id available
+                data.add(project)
+                # Must flush to get the project ID
                 await data.flush()
 
-            # Create release record with product line
+            # Create release record with project
             release = models.Release(
                 storage_key=storage_key,
                 stage=models.ReleaseStage.CANDIDATE,
                 phase=models.ReleasePhase.RELEASE_CANDIDATE,
-                product_id=product.id,
+                project_id=project.id,
                 version=version,
                 created=datetime.datetime.now(datetime.UTC),
             )
@@ -131,13 +131,13 @@ async def root_candidate_create() -> response.Response | 
str:
     # Get PMC objects for all projects the user is a member of
     async with db.session() as data:
         project_list = web_session.committees + web_session.projects
-        user_pmcs = await data.committee(name_in=project_list).all()
+        user_committees = await data.committee(name_in=project_list).all()
 
     # For GET requests, show the form
     return await quart.render_template(
         "candidate-create.html",
         asf_id=web_session.uid,
-        user_pmcs=user_pmcs,
+        user_committees=user_committees,
     )
 
 
@@ -158,17 +158,17 @@ async def root_candidate_review() -> str:
         # TODO: This duplicates code in root_package_add
         releases = await data.release(
             stage=models.ReleaseStage.CANDIDATE,
-            _pmc=True,
+            _committee=True,
             _packages_tasks=True,
         ).all()
 
         # Filter to only show releases for PMCs or PPMCs where the user is a 
member or committer
         user_releases = []
         for r in releases:
-            if r.pmc is None:
+            if r.committee is None:
                 continue
             # For PPMCs the "members" are stored in the committers field
-            if web_session.uid in r.pmc.pmc_members or web_session.uid in 
r.pmc.committers:
+            if (web_session.uid in r.committee.committee_members) or 
(web_session.uid in r.committee.committers):
                 user_releases.append(r)
 
         # time.sleep(0.37)
diff --git a/atr/routes/download.py b/atr/routes/download.py
index b80665a..8496c7e 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -47,7 +47,7 @@ async def root_download_artifact(release_key: str, 
artifact_sha3: str) -> respon
         package = await data.package(
             artifact_sha3=artifact_sha3,
             release_key=release_key,
-            _release_pmc=True,
+            _release_committee=True,
         ).get()
 
         if not package:
@@ -55,9 +55,9 @@ async def root_download_artifact(release_key: str, 
artifact_sha3: str) -> respon
             return quart.redirect(quart.url_for("root_candidate_review"))
 
         # Check permissions
-        if package.release and package.release.pmc:
-            if (web_session.uid not in package.release.pmc.pmc_members) and (
-                web_session.uid not in package.release.pmc.committers
+        if package.release and package.release.committee:
+            if (web_session.uid not in 
package.release.committee.committee_members) and (
+                web_session.uid not in package.release.committee.committers
             ):
                 await quart.flash("You don't have permission to download this 
file", "error")
                 return quart.redirect(quart.url_for("root_candidate_review"))
@@ -86,15 +86,17 @@ async def root_download_signature(release_key: str, 
signature_sha3: str) -> quar
 
     async with db.session() as data:
         # Find the package that has this signature
-        package = await data.package(signature_sha3=signature_sha3, 
release_key=release_key, _release_pmc=True).get()
+        package = await data.package(
+            signature_sha3=signature_sha3, release_key=release_key, 
_release_committee=True
+        ).get()
         if not package:
             await quart.flash("Signature not found", "error")
             return quart.redirect(quart.url_for("root_candidate_review"))
 
         # Check permissions
-        if package.release and package.release.pmc:
-            if (web_session.uid not in package.release.pmc.pmc_members) and (
-                web_session.uid not in package.release.pmc.committers
+        if package.release and package.release.committee:
+            if (web_session.uid not in 
package.release.committee.committee_members) and (
+                web_session.uid not in package.release.committee.committers
             ):
                 await quart.flash("You don't have permission to download this 
file", "error")
                 return quart.redirect(quart.url_for("root_candidate_review"))
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index f737d8c..7831335 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -53,7 +53,7 @@ async def ephemeral_gpg_home() -> AsyncGenerator[str]:
 
 
 async def key_add_post(
-    web_session: session.ClientSession, request: quart.Request, user_pmcs: 
Sequence[models.PMC]
+    web_session: session.ClientSession, request: quart.Request, 
user_committees: Sequence[models.Committee]
 ) -> dict | None:
     form = await routes.get_form(request)
     public_key = form.get("public_key")
@@ -61,21 +61,25 @@ async def key_add_post(
         raise routes.FlashError("Public key is required")
 
     # Get selected PMCs from form
-    selected_pmcs = form.getlist("selected_pmcs")
-    if not selected_pmcs:
+    selected_committees = form.getlist("selected_committees")
+    if not selected_committees:
         raise routes.FlashError("You must select at least one PMC")
 
     # Ensure that the selected PMCs are ones of which the user is actually a 
member
-    invalid_pmcs = [
-        pmc for pmc in selected_pmcs if (pmc not in web_session.committees) 
and (pmc not in web_session.projects)
+    invalid_committees = [
+        committee
+        for committee in selected_committees
+        if (committee not in web_session.committees) and (committee not in 
web_session.projects)
     ]
-    if invalid_pmcs:
-        raise routes.FlashError(f"Invalid PMC selection: {', 
'.join(invalid_pmcs)}")
+    if invalid_committees:
+        raise routes.FlashError(f"Invalid PMC selection: {', 
'.join(invalid_committees)}")
 
-    return await key_user_add(web_session, public_key, selected_pmcs)
+    return await key_user_add(web_session, public_key, selected_committees)
 
 
-async def key_user_add(web_session: session.ClientSession, public_key: str, 
selected_pmcs: list[str]) -> dict | None:
+async def key_user_add(
+    web_session: session.ClientSession, public_key: str, selected_committees: 
list[str]
+) -> dict | None:
     if not public_key:
         raise routes.FlashError("Public key is required")
 
@@ -110,14 +114,14 @@ async def key_user_add(web_session: 
session.ClientSession, public_key: str, sele
 
     # Store key in database
     async with db.session() as data:
-        return await key_user_session_add(web_session, public_key, key, 
selected_pmcs, data)
+        return await key_user_session_add(web_session, public_key, key, 
selected_committees, data)
 
 
 async def key_user_session_add(
     web_session: session.ClientSession,
     public_key: str,
     key: dict,
-    selected_pmcs: list[str],
+    selected_committees: list[str],
     data: db.Session,
 ) -> dict | None:
     # TODO: Check if key already exists
@@ -151,10 +155,10 @@ async def key_user_session_add(
         data.add(key_record)
 
         # Link key to selected PMCs
-        for pmc_name in selected_pmcs:
-            pmc = await data.committee(name=pmc_name).get()
-            if pmc and pmc.id:
-                link = models.PMCKeyLink(pmc_id=pmc.id, 
key_fingerprint=key_record.fingerprint)
+        for committee_name in selected_committees:
+            committee = await data.committee(name=committee_name).get()
+            if committee and committee.id:
+                link = models.KeyLink(committee_id=committee.id, 
key_fingerprint=key_record.fingerprint)
                 data.add(link)
             else:
                 # TODO: Log? Add to "error"?
@@ -183,11 +187,11 @@ async def root_keys_add() -> str:
     # Get PMC objects for all projects the user is a member of
     async with db.session() as data:
         project_list = web_session.committees + web_session.projects
-        user_pmcs = await data.committee(name_in=project_list).all()
+        user_committees = await data.committee(name_in=project_list).all()
 
     if quart.request.method == "POST":
         try:
-            key_info = await key_add_post(web_session, quart.request, 
user_pmcs)
+            key_info = await key_add_post(web_session, quart.request, 
user_committees)
         except routes.FlashError as e:
             logging.exception("FlashError:")
             await quart.flash(str(e), "error")
@@ -198,7 +202,7 @@ async def root_keys_add() -> str:
     return await quart.render_template(
         "keys-add.html",
         asf_id=web_session.uid,
-        user_pmcs=user_pmcs,
+        user_committees=user_committees,
         key_info=key_info,
         algorithms=routes.algorithms,
     )
@@ -243,7 +247,7 @@ async def root_keys_review() -> str:
 
     # Get all existing keys for the user
     async with db.session() as data:
-        user_keys = await data.public_signing_key(apache_uid=web_session.uid, 
_pmcs=True).all()
+        user_keys = await data.public_signing_key(apache_uid=web_session.uid, 
_committees=True).all()
 
     status_message = quart.request.args.get("status_message")
     status_type = quart.request.args.get("status_type")
diff --git a/atr/routes/package.py b/atr/routes/package.py
index 8af4d21..4f65146 100644
--- a/atr/routes/package.py
+++ b/atr/routes/package.py
@@ -197,7 +197,7 @@ async def package_add_validate(
 async def package_data_get(data: db.Session, artifact_sha3: str, release_key: 
str, session_uid: str) -> models.Package:
     """Validate package deletion request and return the package if valid."""
     # Get the package and its associated release
-    package = await data.package(artifact_sha3=artifact_sha3, 
_release_pmc=True).demand(
+    package = await data.package(artifact_sha3=artifact_sha3, 
_release_committee=True).demand(
         routes.FlashError("Package not found")
     )
 
@@ -205,8 +205,10 @@ async def package_data_get(data: db.Session, 
artifact_sha3: str, release_key: st
         raise routes.FlashError("Invalid release key")
 
     # Check permissions
-    if package.release and package.release.pmc:
-        if session_uid not in package.release.pmc.pmc_members and session_uid 
not in package.release.pmc.committers:
+    if package.release and package.release.committee:
+        if (session_uid not in package.release.committee.committee_members) 
and (
+            session_uid not in package.release.committee.committers
+        ):
             raise routes.FlashError("You don't have permission to access this 
package")
 
     return package
@@ -360,16 +362,16 @@ async def root_package_add() -> response.Response | str:
     # Get all releases where the user is a PMC member or committer of the 
associated PMC
     async with db.session() as data:
         # Load all the necessary relationships for the pmc property to work
-        releases = await data.release(stage=models.ReleaseStage.CANDIDATE, 
_pmc=True).all()
+        releases = await data.release(stage=models.ReleaseStage.CANDIDATE, 
_committee=True).all()
 
         # Filter to only show releases for PMCs or PPMCs where the user is a 
member or committer
         # Can we do this in sqlmodel using JSON container operators?
         user_releases = []
         for r in releases:
-            if r.pmc is None:
+            if r.committee is None:
                 continue
             # For PPMCs the "members" are stored in the committers field
-            if web_session.uid in r.pmc.pmc_members or web_session.uid in 
r.pmc.committers:
+            if (web_session.uid in r.committee.committee_members) or 
(web_session.uid in r.committee.committers):
                 user_releases.append(r)
 
     # For GET requests, show the form
@@ -545,8 +547,8 @@ async def task_package_status_get(data: db.Session, 
artifact_sha3: str) -> tuple
 
 async def task_verification_create(data: db.Session, package: models.Package) 
-> list[models.Task]:
     """Create verification tasks for a package."""
-    if not package.release or not package.release.pmc:
-        raise routes.FlashError("Could not determine PMC for package")
+    if not package.release or not package.release.committee:
+        raise routes.FlashError("Could not determine committee for package")
 
     if package.signature_sha3 is None:
         raise routes.FlashError("Package has no signature")
@@ -569,7 +571,7 @@ async def task_verification_create(data: db.Session, 
package: models.Package) ->
             status=models.TaskStatus.QUEUED,
             task_type="verify_signature",
             task_args=[
-                package.release.pmc.name,
+                package.release.committee.name,
                 "releases/" + package.artifact_sha3,
                 "releases/" + package.signature_sha3,
             ],
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 96176bc..be98a24 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -27,7 +27,6 @@ import asfquart.base as base
 import asfquart.session as session
 import atr.db as db
 import atr.db.models as models
-import atr.db.service as service
 import atr.routes as routes
 import atr.util as util
 
@@ -56,10 +55,13 @@ async def add_voting_policy(session: session.ClientSession, 
form: CreateVotePoli
 
     async with db.session() as data:
         async with data.begin():
-            pmc = await 
data.committee(name=name).demand(base.ASFQuartException("PMC not found", 
errorcode=404))
-            if pmc.name not in session.committees:
+            committee = await data.committee(name=name).demand(
+                base.ASFQuartException("Committee not found", errorcode=404)
+            )
+            if committee.name not in session.committees:
                 raise base.ASFQuartException(
-                    f"You must be a PMC member of {pmc.display_name} to submit 
a voting policy", errorcode=403
+                    f"You must be a committee member of 
{committee.display_name} to submit a voting policy",
+                    errorcode=403,
                 )
 
             vote_policy = models.VotePolicy(
@@ -72,24 +74,24 @@ async def add_voting_policy(session: session.ClientSession, 
form: CreateVotePoli
             data.add(vote_policy)
 
     # Redirect to the add package page with the storage token
-    return quart.redirect(quart.url_for("root_project_view", 
project_name=name))
+    return quart.redirect(quart.url_for("root_project_view", name=name))
 
 
 @routes.app_route("/projects")
 async def root_project_directory() -> str:
     """Main project directory page."""
-    async with db.create_async_db_session() as session:
-        projects = await service.get_pmcs(session)
+    async with db.session() as data:
+        projects = await data.project(_committee=True).all()
         return await quart.render_template("project-directory.html", 
projects=projects)
 
 
 @routes.app_route("/projects/<name>")
 async def root_project_view(name: str) -> str:
     async with db.session() as data:
-        pmc = await data.committee(name=name, _public_signing_keys=True, 
_vote_policy=True).demand(
+        project = await data.project(name=name, 
_committee_public_signing_keys=True, _vote_policy=True).demand(
             http.client.HTTPException(404)
         )
-        return await quart.render_template("project-view.html", project=pmc, 
algorithms=routes.algorithms)
+        return await quart.render_template("project-view.html", 
project=project, algorithms=routes.algorithms)
 
 
 @routes.app_route("/projects/<name>/voting/create", methods=["GET", "POST"])
@@ -99,13 +101,17 @@ async def root_project_voting_policy_add(name: str) -> 
response.Response | str:
         raise base.ASFQuartException("Not authenticated", errorcode=401)
 
     async with db.session() as data:
-        pmc = await 
data.committee(name=name).demand(base.ASFQuartException("PMC not found", 
errorcode=404))
-        if pmc.name not in web_session.committees:
+        project = await data.project(name=name, _committee=True).demand(
+            base.ASFQuartException("Project not found", errorcode=404)
+        )
+        if project.committee is None:
+            raise base.ASFQuartException("Project is not associated with a 
committee", errorcode=404)
+        if project.committee.name not in web_session.committees:
             raise base.ASFQuartException(
-                f"You must be a PMC member of {pmc.display_name} to submit a 
voting policy", errorcode=403
+                f"You must be a committee member of {project.display_name} to 
submit a voting policy", errorcode=403
             )
 
-    form = await CreateVotePolicyForm.create_form(data={"project_name": 
pmc.name})
+    form = await CreateVotePolicyForm.create_form(data={"project_name": 
project.name})
 
     if await form.validate_on_submit():
         return await add_voting_policy(web_session, form)
@@ -114,6 +120,6 @@ async def root_project_voting_policy_add(name: str) -> 
response.Response | str:
     return await quart.render_template(
         "vote-policy-add.html",
         asf_id=web_session.uid,
-        project=pmc,
+        project=project,
         form=form,
     )
diff --git a/atr/routes/release.py b/atr/routes/release.py
index 03dd977..bb0f9fa 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -43,13 +43,15 @@ if asfquart.APP is ...:
 
 async def release_delete_validate(data: db.Session, release_key: str, 
session_uid: str) -> models.Release:
     """Validate release deletion request and return the release if valid."""
-    # if Release.pmc is None:
-    #     raise FlashError("Release has no associated PMC")
-    release = await data.release(storage_key=release_key, 
_pmc=True).demand(routes.FlashError("Release not found"))
+    release = await data.release(storage_key=release_key, 
_committee=True).demand(
+        routes.FlashError("Release not found")
+    )
 
     # Check permissions
-    if release.pmc:
-        if (session_uid not in release.pmc.pmc_members) and (session_uid not 
in release.pmc.committers):
+    if release.committee:
+        if (session_uid not in release.committee.committee_members) and (
+            session_uid not in release.committee.committers
+        ):
             raise routes.FlashError("You don't have permission to delete this 
release")
 
     return release
@@ -126,12 +128,14 @@ async def release_bulk_status(task_id: int) -> str | 
response.Response:
         # Debug print the task.task_args using the logger
         logging.debug(f"Task args: {task.task_args}")
         if task.task_args and isinstance(task.task_args, dict) and 
("release_key" in task.task_args):
-            release = await 
data.release(storage_key=task.task_args["release_key"], _pmc=True).get()
+            release = await 
data.release(storage_key=task.task_args["release_key"], _committee=True).get()
 
             # Check whether the user has permission to view this task
             # Either they're a PMC member or committer for the release's PMC
-            if release and release.pmc:
-                if (web_session.uid not in release.pmc.pmc_members) and 
(web_session.uid not in release.pmc.committers):
+            if release and release.committee:
+                if (web_session.uid not in 
release.committee.committee_members) and (
+                    web_session.uid not in release.committee.committers
+                ):
                     await quart.flash("You don't have permission to view this 
task.", "error")
                     return 
quart.redirect(quart.url_for("root_candidate_review"))
 
@@ -170,11 +174,11 @@ async def root_release_vote() -> response.Response | str:
         # These fields are just for testing, we'll do something better in the 
real UI
         gpg_key_id = form.get("gpg_key_id", "")
         commit_hash = form.get("commit_hash", "")
-        if release.pmc is None:
-            raise base.ASFQuartException("Release has no associated PMC", 
errorcode=400)
+        if release.committee is None:
+            raise base.ASFQuartException("Release has no associated 
committee", errorcode=400)
 
         # Prepare email recipient
-        email_to = f"{mailing_list}@{release.pmc.name}.apache.org"
+        email_to = f"{mailing_list}@{release.committee.name}.apache.org"
 
         # Create a task for vote initiation
         task = models.Task(
@@ -214,26 +218,26 @@ def generate_vote_email_preview(release: models.Release) 
-> str:
     version = release.version
 
     # Get PMC details
-    if release.pmc is None:
-        raise base.ASFQuartException("Release has no associated PMC", 
errorcode=400)
-    pmc_name = release.pmc.name
-    pmc_display = release.pmc.display_name
+    if release.committee is None:
+        raise base.ASFQuartException("Release has no associated committee", 
errorcode=400)
+    committee_name = release.committee.name
+    committee_display = release.committee.display_name
 
-    # Get product information
-    product_name = release.product.product_name if release.product else 
"Unknown"
+    # Get project information
+    project_name = release.project.name if release.project else "Unknown"
 
     # Create email subject
-    subject = f"[VOTE] Release Apache {pmc_display} {product_name} {version}"
+    subject = f"[VOTE] Release Apache {committee_display} {project_name} 
{version}"
 
     # Create email body
-    body = f"""Hello {pmc_name},
+    body = f"""Hello {committee_name},
 
 I'd like to call a vote on releasing the following artifacts as
-Apache {pmc_display} {product_name} {version}.
+Apache {committee_display} {project_name} {version}.
 
 The release candidate can be found at:
 
-https://apache.example.org/{pmc_name}/{product_name}-{version}/
+https://apache.example.org/{committee_name}/{project_name}-{version}/
 
 The release artifacts are signed with my GPG key, [KEY_ID].
 
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index 8615229..59a2373 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -44,7 +44,8 @@ input, textarea, button, select, option {
 
 label[for] {
     font-weight: 450;
-    border-bottom: 1px dashed #d1d2d3;
+
+    /* border-bottom: 1px dashed #d1d2d3; */
     cursor: pointer;
 }
 
@@ -163,6 +164,10 @@ textarea {
     min-height: 200px;
 }
 
+summary {
+    cursor: pointer;
+}
+
 form.striking {
     background-color: #ffffee;
     border: 2px solid #ddddbb;
diff --git a/atr/static/css/root.css b/atr/static/css/root.css
deleted file mode 100644
index ada4c11..0000000
--- a/atr/static/css/root.css
+++ /dev/null
@@ -1,58 +0,0 @@
-body {
-    font-family: sans-serif;
-    margin: 2em;
-    line-height: 1.6;
-}
-
-h1 {
-    color: #003366;
-}
-
-.pmc-list {
-    list-style: none;
-    padding: 0;
-    max-width: 1200px;
-}
-
-.pmc-item {
-    padding: 1.5em;
-    margin: 0.75em 0;
-    border: 1px solid #dddddd;
-    border-radius: 8px;
-    background: #ffffff;
-    box-shadow: 0 2px 4px rgb(0 0 0 / 5%);
-    transition: transform 0.2s ease, box-shadow 0.2s ease;
-}
-
-.pmc-item:hover {
-    transform: translateY(-2px);
-    box-shadow: 0 4px 8px rgb(0 0 0 / 10%);
-}
-
-.pmc-name {
-    font-weight: bold;
-    font-size: 1.2em;
-    color: #003366;
-    margin-bottom: 0.5em;
-}
-
-.pmc-stats {
-    color: #666666;
-    display: flex;
-    gap: 1.5em;
-    flex-wrap: wrap;
-}
-
-.stat-item {
-    display: flex;
-    gap: 0.5em;
-}
-
-.intro {
-    max-width: 800px;
-    color: #555555;
-}
-
-.ribbon {
-    background: linear-gradient(90deg, #282661 0%, #662f8f 20%, #9e2165 40%, 
#cb2138 60%, #ea7826 80%, #f7ae18 100%);
-}
diff --git a/atr/tasks/mailtest.py b/atr/tasks/mailtest.py
index 8e298bd..49ac8d1 100644
--- a/atr/tasks/mailtest.py
+++ b/atr/tasks/mailtest.py
@@ -103,7 +103,7 @@ def send_core(args_list: list[str]) -> tuple[task.Status, 
str | None, tuple[Any,
     import asyncio
 
     import atr.mail
-    from atr.db.service import get_pmc_by_name
+    from atr.db.service import get_committee_by_name
 
     _LOGGER.info("Starting send_core")
     try:
@@ -136,10 +136,10 @@ def send_core(args_list: list[str]) -> tuple[task.Status, 
str | None, tuple[Any,
             # Must be a PMC member of tooling
             # Since get_pmc_by_name is async, we need to run it in an event 
loop
             # TODO: We could make a sync version
-            tooling_pmc = asyncio.run(get_pmc_by_name("tooling"))
+            tooling_committee = asyncio.run(get_committee_by_name("tooling"))
 
-            if not tooling_pmc:
-                error_msg = "Tooling PMC not found in database"
+            if not tooling_committee:
+                error_msg = "Tooling committee not found in database"
                 _LOGGER.error(error_msg)
                 return task.FAILED, error_msg, tuple()
 
@@ -148,12 +148,12 @@ def send_core(args_list: list[str]) -> tuple[task.Status, 
str | None, tuple[Any,
                 _LOGGER.error(error_msg)
                 return task.FAILED, error_msg, tuple()
 
-            if local_part not in tooling_pmc.pmc_members:
-                error_msg = f"Email recipient {local_part} is not a member of 
the tooling PMC"
+            if local_part not in tooling_committee.committee_members:
+                error_msg = f"Email recipient {local_part} is not a member of 
the tooling committee"
                 _LOGGER.error(error_msg)
                 return task.FAILED, error_msg, tuple()
 
-            _LOGGER.info(f"Recipient {email_recipient} is a tooling PMC 
member, allowed")
+            _LOGGER.info(f"Recipient {email_recipient} is a tooling committee 
member, allowed")
 
         # Load and set DKIM key
         try:
diff --git a/atr/tasks/signature.py b/atr/tasks/signature.py
index 1c44415..15d5c3e 100644
--- a/atr/tasks/signature.py
+++ b/atr/tasks/signature.py
@@ -41,15 +41,20 @@ def check(args: list[str]) -> tuple[task.Status, str | 
None, tuple[Any, ...]]:
     return status, error, task_results
 
 
-def _check_core(pmc_name: str, artifact_path: str, signature_path: str) -> 
dict[str, Any]:
-    """Verify a signature file using the PMC's public signing keys."""
-    # Query only the signing keys associated with this PMC
+def _check_core(committee_name: str, artifact_path: str, signature_path: str) 
-> dict[str, Any]:
+    """Verify a signature file using the committee's public signing keys."""
+    # Query only the signing keys associated with this committee
     # TODO: Rename create_sync_db_session to create_session_sync
     # Using isinstance does not work here, with pyright
-    name = db.validate_instrumented_attribute(models.PMC.name)
+    name = db.validate_instrumented_attribute(models.Committee.name)
     with db.create_sync_db_session() as session:
         # TODO: This is our only remaining use of select
-        statement = 
sql.select(models.PublicSigningKey).join(models.PMCKeyLink).join(models.PMC).where(name
 == pmc_name)
+        statement = (
+            sql.select(models.PublicSigningKey)
+            .join(models.KeyLink)
+            .join(models.Committee)
+            .where(name == committee_name)
+        )
         result = session.execute(statement)
         public_keys = [key.ascii_armored_key for key in result.scalars().all()]
 
@@ -94,7 +99,7 @@ def _signature_gpg_file(sig_file: BinaryIO, artifact_path: 
str, ascii_armored_ke
         "trust_level": verified.trust_level if hasattr(verified, 
"trust_level") else "Not available",
         "trust_text": verified.trust_text if hasattr(verified, "trust_text") 
else "Not available",
         "stderr": verified.stderr if hasattr(verified, "stderr") else "Not 
available",
-        "num_pmc_keys": len(ascii_armored_keys),
+        "num_committee_keys": len(ascii_armored_keys),
     }
 
     if not verified:
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 866d375..c449628 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -170,29 +170,29 @@ def initiate_core(args_list: list[str]) -> 
tuple[task.Status, str | None, tuple[
             _LOGGER.error(error_msg)
             return task.FAILED, error_msg, tuple()
 
-        # Get PMC and product details
-        if release.pmc is None:
-            error_msg = "Release has no associated PMC"
+        # Get PMC and project details
+        if release.committee is None:
+            error_msg = "Release has no associated committee"
             _LOGGER.error(error_msg)
             return task.FAILED, error_msg, tuple()
 
-        pmc_name = release.pmc.name
-        pmc_display = release.pmc.display_name
-        product_name = release.product.product_name if release.product else 
"Unknown"
+        committee_name = release.committee.name
+        committee_display = release.committee.display_name
+        project_name = release.project.name if release.project else "Unknown"
         version = release.version
 
         # Create email subject
-        subject = f"[VOTE] Release Apache {pmc_display} {product_name} 
{version}"
+        subject = f"[VOTE] Release Apache {committee_display} {project_name} 
{version}"
 
         # Create email body with initiator ID
-        body = f"""Hello {pmc_name},
+        body = f"""Hello {committee_name},
 
 I'd like to call a vote on releasing the following artifacts as
-Apache {pmc_display} {product_name} {version}.
+Apache {committee_display} {project_name} {version}.
 
 The release candidate can be found at:
 
-https://apache.example.org/{pmc_name}/{product_name}-{version}/
+https://apache.example.org/{committee_name}/{project_name}-{version}/
 
 The release artifacts are signed with my GPG key, {gpg_key_id}.
 
diff --git a/atr/templates/candidate-create.html 
b/atr/templates/candidate-create.html
index 1861070..106d30c 100644
--- a/atr/templates/candidate-create.html
+++ b/atr/templates/candidate-create.html
@@ -24,19 +24,19 @@
         enctype="multipart/form-data"
         class="striking py-4 px-5">
     <div class="mb-3 pb-3 row border-bottom">
-      <label for="project_name" class="col-sm-3 col-form-label 
text-sm-end">Project:</label>
+      <label for="committee_name" class="col-sm-3 col-form-label 
text-sm-end">Committee:</label>
       <div class="col-sm-8">
-        <select id="project_name"
-                name="project_name"
+        <select id="committee_name"
+                name="committee_name"
                 class="mb-2 form-select"
                 required>
-          <option value="">Select a project...</option>
-          {% for pmc in user_pmcs|sort(attribute='name') %}
-            <option value="{{ pmc.name }}">{{ pmc.display_name }}</option>
+          <option value="">Select a committee...</option>
+          {% for committee in user_committees|sort(attribute='name') %}
+            <option value="{{ committee.name }}">{{ committee.display_name 
}}</option>
           {% endfor %}
         </select>
-        {% if not user_pmcs %}
-          <p class="text-danger">You must be a PMC member or committer to 
submit a release candidate.</p>
+        {% if not user_committees %}
+          <p class="text-danger">You must be a (P)PMC member or committer to 
submit a release candidate.</p>
         {% endif %}
       </div>
     </div>
@@ -49,14 +49,16 @@
     </div>
 
     <div class="mb-3 pb-3 row border-bottom">
-      <label for="product_name" class="col-sm-3 col-form-label 
text-sm-end">Product name:</label>
+      <label for="project_name" class="col-sm-3 col-form-label 
text-sm-end">Project name:</label>
       <div class="col-sm-8">
-        <!-- TODO: Add a dropdown for the product name, plus "add new product" 
-->
+        <!-- TODO: Add a dropdown for the project name, plus "add new project" 
-->
         <input type="text"
-               id="product_name"
-               name="product_name"
+               id="project_name"
+               name="project_name"
                class="form-control"
                required />
+        <!-- TODO: Add a subproject checkbox -->
+        <small class="text-muted">This can be the same as the committee name, 
but may be different if e.g. this is a subproject.</small>
       </div>
     </div>
 
@@ -64,7 +66,7 @@
       <div class="col-sm-9 offset-sm-3">
         <button type="submit"
                 class="btn btn-primary mt-2"
-                {% if not user_pmcs %}disabled{% endif %}>Create 
release</button>
+                {% if not user_committees %}disabled{% endif %}>Create 
release</button>
       </div>
     </div>
   </form>
diff --git a/atr/templates/candidate-review.html 
b/atr/templates/candidate-review.html
index fe08b65..9004b61 100644
--- a/atr/templates/candidate-review.html
+++ b/atr/templates/candidate-review.html
@@ -32,12 +32,12 @@
     {% for release in releases %}
       <div class="card mb-3 bg-light">
         <div class="card-body">
-          <h3 class="card-title mb-2">{{ release.pmc.display_name }}</h3>
+          <h3 class="card-title mb-2">{{ release.committee.display_name }}</h3>
           <div class="d-flex flex-wrap gap-3 pb-3 mb-2 border-bottom 
candidate-meta text-secondary fs-6">
             <span class="candidate-meta-item">Version: {{ release.version 
}}</span>
             <span class="candidate-meta-item">Stage: {{ release.stage.value 
}}</span>
             <span class="candidate-meta-item">Phase: {{ release.phase.value 
}}</span>
-            <span class="candidate-meta-item">Product: {{ 
release.product.product_name if release.product else "unknown" }}</span>
+            <span class="candidate-meta-item">Project: {{ release.project.name 
if release.project else "unknown" }}</span>
             <span class="candidate-meta-item">Created: {{ 
release.created.strftime("%Y-%m-%d %H:%M UTC") }}</span>
           </div>
           <div class="d-flex gap-3 align-items-center pt-2">
@@ -64,7 +64,7 @@
           <tr>
             <th>Name</th>
             <td>
-              {{ format_artifact_name(release.pmc.name, 
release.product.product_name if release.product else "unknown", 
release.version, release.pmc.is_podling) }}
+              {{ format_artifact_name(release.project.name if release.project 
else "unknown", release.version, release.committee.is_podling) }}
             </td>
           </tr>
           <tr>
diff --git a/atr/templates/keys-add.html b/atr/templates/keys-add.html
index e2f2495..d035e93 100644
--- a/atr/templates/keys-add.html
+++ b/atr/templates/keys-add.html
@@ -68,20 +68,20 @@
       </small>
     </div>
 
-    {% if user_pmcs %}
+    {% if user_committees %}
       <div class="mb-4">
         <div class="mb-3">
           <label class="form-label">Associate with projects:</label>
         </div>
         <div class="d-flex flex-wrap gap-3 mb-2">
-          {% for pmc in user_pmcs|sort(attribute='name') %}
+          {% for committee in user_committees|sort(attribute='name') %}
             <div class="form-check d-flex align-items-center gap-2">
               <input type="checkbox"
                      class="form-check-input"
-                     id="pmc_{{ pmc.name }}"
-                     name="selected_pmcs"
-                     value="{{ pmc.name }}" />
-              <label class="form-check-label mb-0" for="pmc_{{ pmc.name }}">{{ 
pmc.display_name }}</label>
+                     id="committee_{{ committee.name }}"
+                     name="selected_committees"
+                     value="{{ committee.name }}" />
+              <label class="form-check-label mb-0" for="committee_{{ 
committee.name }}">{{ committee.display_name }}</label>
             </div>
           {% endfor %}
         </div>
diff --git a/atr/templates/keys-review.html b/atr/templates/keys-review.html
index a7a5b7a..9196611 100644
--- a/atr/templates/keys-review.html
+++ b/atr/templates/keys-review.html
@@ -67,8 +67,8 @@
                 <tr>
                   <th class="p-2 text-dark">Associated projects</th>
                   <td class="text-break">
-                    {% if key.pmcs %}
-                      {{ key.pmcs|map(attribute='name') |join(', ') }}
+                    {% if key.committees %}
+                      {{ key.committees|map(attribute='name') |join(', ') }}
                     {% else %}
                       No projects associated
                     {% endif %}
diff --git a/atr/templates/package-add.html b/atr/templates/package-add.html
index 1a76ce3..8745dec 100644
--- a/atr/templates/package-add.html
+++ b/atr/templates/package-add.html
@@ -29,7 +29,7 @@
               {% for release in releases %}
                 <option value="{{ release.storage_key }}"
                         {% if release.storage_key == selected_release 
%}selected{% endif %}>
-                  {{ release.pmc.display_name }} - {{ 
release.product.product_name if release.product else "unknown" }} - {{ 
release.version }}
+                  {{ release.committee.display_name }} - {{ 
release.project.name if release.project else "unknown" }} - {{ release.version 
}}
                 </option>
               {% endfor %}
             </select>
@@ -155,7 +155,7 @@
               {% for release in releases %}
                 <option value="{{ release.storage_key }}"
                         {% if release.storage_key == selected_release 
%}selected{% endif %}>
-                  {{ release.pmc.display_name }} - {{ 
release.product.product_name if release.product else "unknown" }} - {{ 
release.version }}
+                  {{ release.committee.display_name }} - {{ 
release.project.name if release.project else "unknown" }} - {{ release.version 
}}
                 </option>
               {% endfor %}
             </select>
diff --git a/atr/templates/project-directory.html 
b/atr/templates/project-directory.html
index 903940c..cd134dc 100644
--- a/atr/templates/project-directory.html
+++ b/atr/templates/project-directory.html
@@ -14,24 +14,24 @@
 
   <div class="mb-3">
     <input type="text"
-           id="pmc-filter"
+           id="project-filter"
            class="form-control d-inline-block w-auto" />
     <button type="button" class="btn btn-primary" 
id="filter-button">Filter</button>
   </div>
 
   <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
-    {% for pmc in projects %}
+    {% for project in projects %}
       <div class="col">
         <div class="card h-100 shadow-sm cursor-pointer hover-lift 
project-card"
-             data-project-url="{{ url_for('root_project_view', name=pmc.name) 
}}">
+             data-project-url="{{ url_for('root_project_view', 
name=project.name) }}">
           <div class="card-body">
-            <h2 class="card-title fs-4 mb-3">{{ pmc.display_name }}</h2>
+            <h2 class="card-title fs-4 mb-3">{{ project.display_name }}</h2>
             <div class="row g-3">
               <div class="col-4">
                 <div class="card h-100 bg-light border-0">
                   <div class="card-body p-2 d-flex flex-column 
justify-content-between text-center">
-                    <small class="text-secondary">PMC Members</small>
-                    <span class="fs-4 fw-medium mt-2">{{ 
pmc.pmc_members|length }}</span>
+                    <small class="text-secondary">Project committee 
members</small>
+                    <span class="fs-4 fw-medium mt-2">{{ 
project.committee.committee_members|length }}</span>
                   </div>
                 </div>
               </div>
@@ -39,7 +39,7 @@
                 <div class="card h-100 bg-light border-0">
                   <div class="card-body p-2 d-flex flex-column 
justify-content-between text-center">
                     <small class="text-secondary">Committers</small>
-                    <span class="fs-4 fw-medium mt-2">{{ pmc.committers|length 
}}</span>
+                    <span class="fs-4 fw-medium mt-2">{{ 
project.committers|length }}</span>
                   </div>
                 </div>
               </div>
@@ -47,7 +47,7 @@
                 <div class="card h-100 bg-light border-0">
                   <div class="card-body p-2 d-flex flex-column 
justify-content-between text-center">
                     <small class="text-secondary">Release Managers</small>
-                    <span class="fs-4 fw-medium mt-2">{{ 
pmc.release_managers|length }}</span>
+                    <span class="fs-4 fw-medium mt-2">{{ 
project.release_managers|length }}</span>
                   </div>
                 </div>
               </div>
@@ -63,22 +63,22 @@
   {{ super() }}
   <script>
       function filter() {
-          const pmcFilter = document.getElementById("pmc-filter").value;
+          const projectFilter = 
document.getElementById("project-filter").value;
           const cards = document.querySelectorAll(".project-card");
           for (let card of cards) {
               const nameElement = card.querySelector(".card-title");
               const name = nameElement.innerHTML;
-              if (!pmcFilter) {
+              if (!projectFilter) {
                   card.parentElement.hidden = false;
               } else {
-                  card.parentElement.hidden = !name.match(new 
RegExp(pmcFilter, 'i'));
+                  card.parentElement.hidden = !name.match(new 
RegExp(projectFilter, 'i'));
               }
           }
       }
 
       // Add event listeners
       document.getElementById("filter-button").addEventListener("click", 
filter);
-      document.getElementById("pmc-filter").addEventListener("keydown", 
function(event) {
+      document.getElementById("project-filter").addEventListener("keydown", 
function(event) {
           if (event.key === "Enter") {
               filter();
               event.preventDefault();
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 9945c52..d773aea 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -17,7 +17,7 @@
     </div>
     <div class="card-body">
       <div class="d-flex flex-wrap gap-3 small mb-1">
-        <span>PMC: {{ project.pmc_members|length }}</span>
+        <span>Committee members: {{ project.committee.committee_members|length 
}}</span>
         <span class="d-flex align-items-center">
           <span>Committers: {{ project.committers|length }}</span>
         </span>
@@ -90,7 +90,6 @@
     <div class="card-header bg-light d-flex justify-content-between 
align-items-center">
       <h3 class="mb-0">Voting Policy</h3>
       <div>
-        <!-- TODO: This is a PMC, not a project -->
         <a class="btn btn-primary"
            href="{{ url_for('root_project_voting_policy_add', 
name=project.name) }}"><i class="fa-solid fa-plus"></i></a>
       </div>
@@ -114,12 +113,14 @@
     </div>
   </div>
 
+  <!-- TODO: Subprojects?
   <div class="card mb-4">
     <div class="card-header bg-light">
-      <h3 class="mb-0">Product Lines</h3>
+      <h3 class="mb-0">Projects</h3>
     </div>
     <div class="card-body"></div>
   </div>
+  -->
 
 {% endblock content %}
 
diff --git a/atr/templates/release-bulk.html b/atr/templates/release-bulk.html
index c77d858..850be68 100644
--- a/atr/templates/release-bulk.html
+++ b/atr/templates/release-bulk.html
@@ -23,8 +23,8 @@
              class="text-decoration-none">Release candidates</a>
         </li>
         {% if release %}
-          <li class="breadcrumb-item">{{ release.pmc.display_name }}</li>
-          <li class="breadcrumb-item">{{ release.product.product_name if 
release.product else "Unknown product" }}</li>
+          <li class="breadcrumb-item">{{ release.committee.display_name }}</li>
+          <li class="breadcrumb-item">{{ release.project.name if 
release.project else "Unknown project" }}</li>
           <li class="breadcrumb-item">{{ release.version }}</li>
         {% endif %}
         <li class="breadcrumb-item active">Bulk download status</li>
diff --git a/atr/templates/release-vote.html b/atr/templates/release-vote.html
index 0ea117e..1acb71c 100644
--- a/atr/templates/release-vote.html
+++ b/atr/templates/release-vote.html
@@ -14,7 +14,7 @@
 
     <div class="px-3 pb-4 mb-4 bg-light border rounded">
       <h2 class="mt-4 mb-3 fs-5 border-0">
-        {{ release.pmc.display_name }} - {{ release.product.product_name if 
release.product else "Unknown" }} {{ release.version }}
+        {{ release.committee.display_name }} - {{ release.project.name if 
release.project else "Unknown" }} {{ release.version }}
       </h2>
       <p class="mb-0">
         Initiating a vote for this release candidate will prepare an email to 
be sent to the appropriate mailing list.
@@ -44,7 +44,7 @@
                        name="mailing_list"
                        value="dev"
                        checked />
-                <label class="form-check-label" for="list_dev">dev@{{ 
release.pmc.name }}.apache.org</label>
+                <label class="form-check-label" for="list_dev">dev@{{ 
release.committee.name }}.apache.org</label>
               </div>
               <div class="form-check">
                 <input type="radio"
@@ -52,7 +52,7 @@
                        id="list_private"
                        name="mailing_list"
                        value="private" />
-                <label class="form-check-label" for="list_private">private@{{ 
release.pmc.name }}.apache.org</label>
+                <label class="form-check-label" for="list_private">private@{{ 
release.committee.name }}.apache.org</label>
               </div>
             </div>
           </div>
diff --git a/docs/plan.html b/docs/plan.html
index c6c447f..4c15092 100644
--- a/docs/plan.html
+++ b/docs/plan.html
@@ -24,7 +24,7 @@
 <li>[DONE] Add file size and upload timestamp</li>
 <li>[DONE] Improve the layout of file listings</li>
 <li>[DONE] Show KB, MB, or GB units for file sizes</li>
-<li>[DONE] Add a standard artifact naming pattern based on the project and 
product</li>
+<li>[DONE] Add a standard artifact naming pattern based on the committee and 
project</li>
 <li>[DONE] Potentially add the option to upload package artifacts without 
signatures</li>
 <li>[DONE] Show validation status indicators</li>
 <li>[DONE] Add developer RC download buttons with clear verification 
instructions</li>
diff --git a/docs/plan.md b/docs/plan.md
index 88ca3f4..91c8d46 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -21,7 +21,7 @@ This is a rough plan of immediate tasks. The priority of 
these tasks may change,
    - [DONE] Add file size and upload timestamp
    - [DONE] Improve the layout of file listings
    - [DONE] Show KB, MB, or GB units for file sizes
-   - [DONE] Add a standard artifact naming pattern based on the project and 
product
+   - [DONE] Add a standard artifact naming pattern based on the committee and 
project
    - [DONE] Potentially add the option to upload package artifacts without 
signatures
    - [DONE] Show validation status indicators
    - [DONE] Add developer RC download buttons with clear verification 
instructions


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]


Reply via email to