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 d37b211  Add a Project class and improve the model in general
d37b211 is described below

commit d37b2111239f91b8d863bd4480452a6d5e3081ae
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Mar 13 19:33:13 2025 +0200

    Add a Project class and improve the model in general
---
 atr/blueprints/admin/admin.py        |  19 ++--
 atr/blueprints/api/api.py            |   6 +-
 atr/db/__init__.py                   |  12 ++-
 atr/db/models.py                     |  93 +++++++++++++-----
 atr/db/service.py                    |  18 ++--
 atr/routes/candidate.py              |  45 +++++----
 atr/routes/keys.py                   |   6 +-
 atr/routes/package.py                |  32 ++++---
 atr/routes/project.py                |  24 ++---
 atr/routes/release.py                |  12 ++-
 atr/tasks/bulk.py                    |  36 +++----
 atr/tasks/signature.py               |   6 +-
 atr/tasks/vote.py                    |   4 +-
 atr/templates/candidate-create.html  |   4 +-
 atr/templates/candidate-review.html  |   4 +-
 atr/templates/keys-add.html          |   8 +-
 atr/templates/keys-review.html       |   2 +-
 atr/templates/package-add.html       |   4 +-
 atr/templates/project-directory.html |   2 +-
 atr/templates/project-view.html      |   3 +-
 atr/templates/release-bulk.html      |   2 +-
 atr/templates/release-vote.html      | 179 +++++++++++++++++------------------
 atr/worker.py                        |   2 +-
 docs/plan.html                       |   1 +
 docs/plan.md                         |   1 +
 25 files changed, 292 insertions(+), 233 deletions(-)

diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 4da9ae8..80690a9 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -125,10 +125,11 @@ async def admin_data(model: str = "PMC") -> str:
     # Map of model names to their classes
     model_classes = {
         "PMC": models.PMC,
+        "Project": models.Project,
         "Release": models.Release,
         "Package": models.Package,
         "VotePolicy": models.VotePolicy,
-        "ProductLine": models.ProductLine,
+        "Product": models.Product,
         "DistributionChannel": models.DistributionChannel,
         "PublicSigningKey": models.PublicSigningKey,
         "PMCKeyLink": models.PMCKeyLink,
@@ -208,8 +209,10 @@ async def _update_pmcs() -> int:
                 # Get or create PMC
                 pmc = await service.get_pmc_by_name(name, db_session)
                 if not pmc:
-                    pmc = models.PMC(project_name=name)
+                    pmc = models.PMC(name=name)
                     db_session.add(pmc)
+                    pmc_core_project = models.Project(name=name, pmc=pmc)
+                    db_session.add(pmc_core_project)
 
                 # Update PMC data from groups.json
                 pmc_members = groups_data.get(f"{name}-pmc")
@@ -230,11 +233,13 @@ async def _update_pmcs() -> int:
             # Then add PPMCs (podlings)
             for podling_name, podling_data in podlings_data:
                 # Get or create PPMC
-                statement = 
sqlmodel.select(models.PMC).where(models.PMC.project_name == podling_name)
+                statement = sqlmodel.select(models.PMC).where(models.PMC.name 
== podling_name)
                 ppmc = (await 
db_session.execute(statement)).scalar_one_or_none()
                 if not ppmc:
-                    ppmc = models.PMC(project_name=podling_name)
+                    ppmc = models.PMC(name=podling_name, is_podling=True)
                     db_session.add(ppmc)
+                    ppmc_core_project = models.Project(name=podling_name, 
is_podling=True, pmc=ppmc)
+                    db_session.add(ppmc_core_project)
 
                 # Update PPMC data from groups.json
                 ppmc.is_podling = True
@@ -249,11 +254,13 @@ async def _update_pmcs() -> int:
 
             # Add special entry for Tooling PMC
             # Not clear why, but it's not in the Whimsy data
-            statement = 
sqlmodel.select(models.PMC).where(models.PMC.project_name == "tooling")
+            statement = sqlmodel.select(models.PMC).where(models.PMC.name == 
"tooling")
             tooling_pmc = (await 
db_session.execute(statement)).scalar_one_or_none()
             if not tooling_pmc:
-                tooling_pmc = models.PMC(project_name="tooling")
+                tooling_pmc = models.PMC(name="tooling")
                 db_session.add(tooling_pmc)
+                tooling_project = models.Project(name="tooling", 
pmc=tooling_pmc)
+                db_session.add(tooling_project)
                 updated_count += 1
 
             # Update Tooling PMC data
diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 6ae0414..2038569 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -32,10 +32,10 @@ from atr.db.service import get_pmc_by_name, get_pmcs, 
get_tasks
 #        For now, just explicitly dump the model.
 
 
[email protected]("/projects/<project_name>")
[email protected]("/projects/<name>")
 @validate_response(PMC, 200)
-async def project_by_name(project_name: str) -> tuple[Mapping, int]:
-    pmc = await get_pmc_by_name(project_name)
+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:
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index b9f8b9d..6d2cbba 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -148,13 +148,17 @@ def select_in_load(*entities: Any) -> 
orm.strategy_options._AbstractLoad:
     return orm.selectinload(*validated_entities)
 
 
-def select_in_load_nested(parent: Any, child: Any) -> 
orm.strategy_options._AbstractLoad:
+def select_in_load_nested(parent: Any, *descendants: Any) -> 
orm.strategy_options._AbstractLoad:
     """Eagerly load the given nested entities from the query."""
     if not isinstance(parent, orm.InstrumentedAttribute):
         raise ValueError(f"Parent must be an orm.InstrumentedAttribute, got: 
{type(parent)}")
-    if not isinstance(child, orm.InstrumentedAttribute):
-        raise ValueError(f"Child must be an orm.InstrumentedAttribute, got: 
{type(child)}")
-    return orm.selectinload(parent).selectinload(child)
+    for descendant in descendants:
+        if not isinstance(descendant, orm.InstrumentedAttribute):
+            raise ValueError(f"Descendant must be an 
orm.InstrumentedAttribute, got: {type(descendant)}")
+    result = orm.selectinload(parent)
+    for descendant in descendants:
+        result = result.selectinload(descendant)
+    return result
 
 
 def validate_instrumented_attribute(obj: Any) -> orm.InstrumentedAttribute:
diff --git a/atr/db/models.py b/atr/db/models.py
index 59ab1ef..917d9d0 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -73,20 +73,30 @@ class VotePolicy(sqlmodel.SQLModel, table=True):
 
     # 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 projects
+    projects: list["Project"] = 
sqlmodel.Relationship(back_populates="vote_policy")
     # One-to-many: A vote policy can be used by multiple product lines
-    product_lines: list["ProductLine"] = 
sqlmodel.Relationship(back_populates="vote_policy")
+    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):
     id: int = sqlmodel.Field(default=None, primary_key=True)
-    project_name: str = sqlmodel.Field(unique=True)
-    # True if this is an incubator podling with a PPMC, otherwise False
+    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 product lines, each product line 
belongs to one PMC
-    product_lines: list["ProductLine"] = 
sqlmodel.Relationship(back_populates="pmc")
+    # One-to-many: A PMC can have multiple child PMCs, each child PMC belongs 
to one parent PMC
+    child_pmcs: list["PMC"] = sqlmodel.Relationship(
+        sa_relationship_kwargs=dict(
+            backref=sqlalchemy.orm.backref("parent_pmc", remote_side="PMC.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")
 
     pmc_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))
@@ -99,36 +109,59 @@ class PMC(sqlmodel.SQLModel, table=True):
     vote_policy_id: int | None = sqlmodel.Field(default=None, 
foreign_key="votepolicy.id")
     vote_policy: VotePolicy | None = 
sqlmodel.Relationship(back_populates="pmcs")
 
-    # One-to-many: A PMC can have multiple releases
-    releases: list["Release"] = sqlmodel.Relationship(back_populates="pmc")
-
     @property
     def display_name(self) -> str:
         """Get the display name for the PMC/PPMC."""
-        if self.is_podling:
-            return f"{self.project_name} (podling)"
-        return self.project_name
+        name = self.name if self.full_name is None else self.full_name
+        return f"{name} (PPMC)" if self.is_podling else name
 
 
-class ProductLine(sqlmodel.SQLModel, table=True):
-    id: int = sqlmodel.Field(default=None, primary_key=True)
+class Project(sqlmodel.SQLModel, table=True):
+    id: int | None = 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
+    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 = sqlmodel.Field(foreign_key="pmc.id")
-    pmc: PMC = sqlmodel.Relationship(back_populates="product_lines")
+    pmc_id: int | None = sqlmodel.Field(default=None, foreign_key="pmc.id")
+    pmc: PMC | None = sqlmodel.Relationship(back_populates="projects")
+
+    # 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")
+
+    # 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")
+    vote_policy: VotePolicy | None = 
sqlmodel.Relationship(back_populates="projects")
+
+    @property
+    def display_name(self) -> str:
+        """Get the display name for the Project."""
+        name = self.name if self.full_name is None else self.full_name
+        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_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="product_lines")
+    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_line")
+    releases: list["Release"] = sqlmodel.Relationship(back_populates="product")
 
 
 class DistributionChannel(sqlmodel.SQLModel, table=True):
@@ -140,8 +173,8 @@ class DistributionChannel(sqlmodel.SQLModel, table=True):
     automation_endpoint: str
 
     # Many-to-one: A distribution channel belongs to one product line, a 
product line can have multiple channels
-    product_line_id: int = sqlmodel.Field(foreign_key="productline.id")
-    product_line: ProductLine = 
sqlmodel.Relationship(back_populates="distribution_channels")
+    product_id: int = sqlmodel.Field(foreign_key="product.id")
+    product: Product = 
sqlmodel.Relationship(back_populates="distribution_channels")
 
 
 class Package(sqlmodel.SQLModel, table=True):
@@ -260,15 +293,15 @@ class Release(sqlmodel.SQLModel, table=True):
     phase: ReleasePhase
     created: datetime.datetime
 
-    # Many-to-one: A release belongs to one PMC, a PMC can have multiple 
releases
-    pmc_id: int = sqlmodel.Field(foreign_key="pmc.id")
-    pmc: PMC = sqlmodel.Relationship(back_populates="releases")
-
     # Many-to-one: A release belongs to one product line, a product line can 
have multiple releases
-    product_line_id: int = sqlmodel.Field(foreign_key="productline.id")
-    product_line: ProductLine = 
sqlmodel.Relationship(back_populates="releases")
+    product_id: int = sqlmodel.Field(foreign_key="product.id")
+    product: Product = 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
+    # We could either make this str | None, or we could require version to be 
set on packages only
+    # For example, Apache Airflow Providers do not have an overall version
+    # They have one version per package, i.e. per provider
     version: str
     # One-to-many: A release can have multiple packages
     packages: list[Package] = sqlmodel.Relationship(back_populates="release")
@@ -279,3 +312,11 @@ class Release(sqlmodel.SQLModel, table=True):
     vote_policy: VotePolicy | None = 
sqlmodel.Relationship(back_populates="releases")
 
     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
+        if project is None:
+            return None
+        return project.pmc
diff --git a/atr/db/service.py b/atr/db/service.py
index 9ec7970..9a50fba 100644
--- a/atr/db/service.py
+++ b/atr/db/service.py
@@ -26,12 +26,10 @@ import atr.db as db
 import atr.db.models as models
 
 
-async def get_pmc_by_name(
-    project_name: str, session: sqlalchemy.ext.asyncio.AsyncSession | None = 
None
-) -> models.PMC | None:
+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 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.project_name 
== project_name)
+        statement = sqlmodel.select(models.PMC).where(models.PMC.name == name)
         pmc = (await db_session.execute(statement)).scalar_one_or_none()
         return pmc
 
@@ -40,7 +38,7 @@ async def get_pmcs(session: 
sqlalchemy.ext.asyncio.AsyncSession | None = None) -
     """Returns a list of PMC 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.project_name)
+        statement = sqlmodel.select(models.PMC).order_by(models.PMC.name)
         pmcs = (await db_session.execute(statement)).scalars().all()
         return pmcs
 
@@ -48,12 +46,11 @@ async def get_pmcs(session: 
sqlalchemy.ext.asyncio.AsyncSession | None = None) -
 async def get_release_by_key(storage_key: str) -> models.Release | None:
     """Get a release by its storage key."""
     async with db.create_async_db_session() as db_session:
-        # Get the release with its PMC and product line
+        # Get the release
         query = (
             sqlmodel.select(models.Release)
             .where(models.Release.storage_key == storage_key)
-            .options(db.select_in_load(models.Release.pmc))
-            .options(db.select_in_load(models.Release.product_line))
+            .options(db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc))
         )
         result = await db_session.execute(query)
         return result.scalar_one_or_none()
@@ -62,12 +59,11 @@ async def get_release_by_key(storage_key: str) -> 
models.Release | None:
 def get_release_by_key_sync(storage_key: str) -> models.Release | None:
     """Synchronous version of get_release_by_key for use in background 
tasks."""
     with db.create_sync_db_session() as session:
-        # Get the release with its PMC and product line
+        # Get the release
         query = (
             sqlmodel.select(models.Release)
             .where(models.Release.storage_key == storage_key)
-            .options(db.select_in_load(models.Release.pmc))
-            .options(db.select_in_load(models.Release.product_line))
+            .options(db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc))
         )
         result = session.execute(query)
         return result.scalar_one_or_none()
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 158ff23..1e5ed79 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -70,17 +70,15 @@ async def release_add_post(session: session.ClientSession, 
request: quart.Reques
     # Create the release record in the database
     async with db.create_async_db_session() as db_session:
         async with db_session.begin():
-            statement = 
sqlmodel.select(models.PMC).where(models.PMC.project_name == project_name)
+            statement = sqlmodel.select(models.PMC).where(models.PMC.name == 
project_name)
             pmc = (await db_session.execute(statement)).scalar_one_or_none()
             if not pmc:
                 asfquart.APP.logger.error(f"PMC not found for project 
{project_name}")
-                asfquart.APP.logger.debug(f"Available committees: 
{session.committees}")
-                asfquart.APP.logger.debug(f"Available projects: 
{session.projects}")
-                raise base.ASFQuartException("PMC not found", errorcode=404)
+                raise base.ASFQuartException("Project 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 project_name not in session.committees and project_name not in 
session.projects:
+            if pmc.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",
                     errorcode=403,
@@ -91,16 +89,28 @@ async def release_add_post(session: session.ClientSession, 
request: quart.Reques
             storage_key = secrets.token_hex(16)
 
             # Create or get existing product line
-            statement = sqlmodel.select(models.ProductLine).where(
-                models.ProductLine.pmc_id == pmc.id, 
models.ProductLine.product_name == product_name
+            on_clause = 
db.validate_instrumented_attribute(models.Product.project_id) == 
models.Project.id
+            statement = (
+                sqlmodel.select(models.Product)
+                .join(models.Project, on_clause)
+                .where(
+                    models.Project.pmc_id == pmc.id,
+                    models.Product.product_name == product_name,
+                )
             )
-            product_line = (await 
db_session.execute(statement)).scalar_one_or_none()
+            product = (await 
db_session.execute(statement)).scalar_one_or_none()
 
-            if not product_line:
+            if not product:
                 # Create new product line if it doesn't exist
-                product_line = models.ProductLine(pmc_id=pmc.id, 
product_name=product_name, latest_version=version)
-                db_session.add(product_line)
-                # Flush to get the product_line.id
+                statement = sqlmodel.select(models.Project).where(
+                    models.Project.pmc_id == pmc.id, models.Project.name == 
project_name
+                )
+                project = (await 
db_session.execute(statement)).scalar_one_or_none()
+                if not project:
+                    raise base.ASFQuartException(f"Project {project_name} not 
found", errorcode=404)
+                product = models.Product(project_id=project.id, 
product_name=product_name, latest_version=version)
+                db_session.add(product)
+                # Flush to make the product.id available
                 await db_session.flush()
 
             # Create release record with product line
@@ -108,8 +118,7 @@ async def release_add_post(session: session.ClientSession, 
request: quart.Reques
                 storage_key=storage_key,
                 stage=models.ReleaseStage.CANDIDATE,
                 phase=models.ReleasePhase.RELEASE_CANDIDATE,
-                pmc_id=pmc.id,
-                product_line_id=product_line.id,
+                product_id=product.id,
                 version=version,
                 created=datetime.datetime.now(datetime.UTC),
             )
@@ -138,8 +147,8 @@ async def root_candidate_create() -> response.Response | 
str:
     async with db.create_async_db_session() as db_session:
         project_list = web_session.committees + web_session.projects
         # Using isinstance also works here
-        project_name = 
db.validate_instrumented_attribute(models.PMC.project_name)
-        statement = 
sqlmodel.select(models.PMC).where(project_name.in_(project_list))
+        pmc_name = db.validate_instrumented_attribute(models.PMC.name)
+        statement = 
sqlmodel.select(models.PMC).where(pmc_name.in_(project_list))
         user_pmcs = (await db_session.execute(statement)).scalars().all()
 
     # For GET requests, show the form
@@ -168,11 +177,9 @@ async def root_candidate_review() -> str:
         statement = (
             sqlmodel.select(models.Release)
             .options(
-                db.select_in_load(models.Release.pmc),
-                db.select_in_load(models.Release.product_line),
+                db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc),
                 db.select_in_load_nested(models.Release.packages, 
models.Package.tasks),
             )
-            .join(models.PMC)
             .where(models.Release.stage == models.ReleaseStage.CANDIDATE)
         )
         releases = (await db_session.execute(statement)).scalars().all()
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index f101ad0..a3cde72 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -154,7 +154,7 @@ async def key_user_session_add(
 
         # Link key to selected PMCs
         for pmc_name in selected_pmcs:
-            pmc_statement = 
sqlmodel.select(models.PMC).where(models.PMC.project_name == pmc_name)
+            pmc_statement = sqlmodel.select(models.PMC).where(models.PMC.name 
== pmc_name)
             pmc = (await 
db_session.execute(pmc_statement)).scalar_one_or_none()
             if pmc and pmc.id:
                 link = models.PMCKeyLink(pmc_id=pmc.id, 
key_fingerprint=key_record.fingerprint)
@@ -187,8 +187,8 @@ async def root_keys_add() -> str:
     async with db.create_async_db_session() as db_session:
         project_list = web_session.committees + web_session.projects
         # Using isinstance also works here
-        project_name = 
db.validate_instrumented_attribute(models.PMC.project_name)
-        pmc_statement = 
sqlmodel.select(models.PMC).where(project_name.in_(project_list))
+        pmc_name = db.validate_instrumented_attribute(models.PMC.name)
+        pmc_statement = 
sqlmodel.select(models.PMC).where(pmc_name.in_(project_list))
         user_pmcs = (await db_session.execute(pmc_statement)).scalars().all()
 
     if quart.request.method == "POST":
diff --git a/atr/routes/package.py b/atr/routes/package.py
index 2a272ce..d99797d 100644
--- a/atr/routes/package.py
+++ b/atr/routes/package.py
@@ -206,13 +206,14 @@ async def package_data_get(
 ) -> models.Package:
     """Validate package deletion request and return the package if valid."""
     # Get the package and its associated release
-    # if Package.release is None:
-    #     raise FlashError("Package has no associated release")
-    # if Release.pmc is None:
-    #     raise FlashError("Release has no associated PMC")
     statement = (
         sqlmodel.select(models.Package)
-        .options(db.select_in_load_nested(models.Package.release, 
models.Release.pmc))
+        .options(
+            db.select_in_load(models.Package.release),
+            db.select_in_load_nested(
+                models.Package.release, models.Release.product, 
models.Product.project, models.Project.pmc
+            ),
+        )
         .where(models.Package.artifact_sha3 == artifact_sha3)
     )
     result = await db_session.execute(statement)
@@ -339,7 +340,14 @@ async def package_add_bulk_post(form: 
datastructures.MultiDict, request: quart.R
             task = models.Task(
                 status=models.TaskStatus.QUEUED,
                 task_type="package_bulk_download",
-                task_args=[release_key, url, file_types, require_signatures, 
max_depth, max_concurrency],
+                task_args={
+                    "release_key": release_key,
+                    "base_url": url,
+                    "file_types": file_types,
+                    "require_sigs": require_signatures,
+                    "max_depth": max_depth,
+                    "max_concurrent": max_concurrency,
+                },
             )
             db_session.add(task)
             # Flush to get the task ID
@@ -372,13 +380,13 @@ 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.create_async_db_session() as db_session:
-        # TODO: This duplicates code in root_candidate_review
-        release_pmc = db.select_in_load(models.Release.pmc)
-        release_product_line = db.select_in_load(models.Release.product_line)
+        # Load all the necessary relationships for the pmc property to work
+        project_pmc = db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc)
+
+        # Only query releases in candidate stage
         statement = (
             sqlmodel.select(models.Release)
-            .options(release_pmc, release_product_line)
-            .join(models.PMC)
+            .options(project_pmc)
             .where(models.Release.stage == models.ReleaseStage.CANDIDATE)
         )
         releases = (await db_session.execute(statement)).scalars().all()
@@ -595,7 +603,7 @@ async def task_verification_create(
             status=models.TaskStatus.QUEUED,
             task_type="verify_signature",
             task_args=[
-                package.release.pmc.project_name,
+                package.release.pmc.name,
                 "releases/" + package.artifact_sha3,
                 "releases/" + package.signature_sha3,
             ],
diff --git a/atr/routes/project.py b/atr/routes/project.py
index 784fc4d..3e8bd8c 100644
--- a/atr/routes/project.py
+++ b/atr/routes/project.py
@@ -42,12 +42,12 @@ async def root_project_directory() -> str:
         return await quart.render_template("project-directory.html", 
projects=projects)
 
 
[email protected]_route("/projects/<project_name>")
-async def root_project_view(project_name: str) -> str:
[email protected]_route("/projects/<name>")
+async def root_project_view(name: str) -> str:
     async with db.create_async_db_session() as db_session:
         statement = (
             sqlmodel.select(models.PMC)
-            .where(models.PMC.project_name == project_name)
+            .where(models.PMC.name == name)
             .options(
                 db.select_in_load(models.PMC.public_signing_keys),
                 db.select_in_load(models.PMC.vote_policy),
@@ -62,26 +62,26 @@ async def root_project_view(project_name: str) -> str:
         return await quart.render_template("project-view.html", 
project=project, algorithms=routes.algorithms)
 
 
[email protected]_route("/projects/<project_name>/voting/create", methods=["GET", 
"POST"])
-async def root_project_voting_policy_add(project_name: str) -> 
response.Response | str:
[email protected]_route("/projects/<name>/voting/create", methods=["GET", "POST"])
+async def root_project_voting_policy_add(name: str) -> response.Response | str:
     web_session = await session.read()
     if web_session is None:
         raise base.ASFQuartException("Not authenticated", errorcode=401)
 
     async with db.create_async_db_session() as db_session:
-        statement = sqlmodel.select(models.PMC).where(models.PMC.project_name 
== project_name)
+        statement = sqlmodel.select(models.PMC).where(models.PMC.name == name)
         pmc = (await db_session.execute(statement)).scalar_one_or_none()
 
         if not pmc:
             raise base.ASFQuartException("PMC not found", errorcode=404)
-        elif pmc.project_name not in web_session.committees:
+        elif pmc.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
             )
 
     # TODO: the create_form method does not return the correct type but 
QuartForm
     #       we should create our own baseclass that correctly add typing info
-    form = await CreateVotePolicyForm.create_form(data={"project_name": 
project_name})
+    form = await CreateVotePolicyForm.create_form(data={"project_name": 
pmc.name})
 
     if await form.validate_on_submit():
         return await add_voting_policy(web_session, form)  # pyright: ignore 
[reportArgumentType]
@@ -115,15 +115,15 @@ class CreateVotePolicyForm(quart_wtf.QuartForm):
 
 
 async def add_voting_policy(session: session.ClientSession, form: 
CreateVotePolicyForm) -> response.Response:
-    project_name = form.project_name.data
+    name = form.project_name.data
 
     async with db.create_async_db_session() as db_session:
         async with db_session.begin():
-            statement = 
sqlmodel.select(models.PMC).where(models.PMC.project_name == project_name)
+            statement = sqlmodel.select(models.PMC).where(models.PMC.name == 
name)
             pmc = (await db_session.execute(statement)).scalar_one_or_none()
             if not pmc:
                 raise base.ASFQuartException("PMC not found", errorcode=404)
-            elif pmc.project_name not in session.committees:
+            elif pmc.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
                 )
@@ -138,4 +138,4 @@ async def add_voting_policy(session: session.ClientSession, 
form: CreateVotePoli
             db_session.add(vote_policy)
 
     # Redirect to the add package page with the storage token
-    return quart.redirect(quart.url_for("root_project_view", 
project_name=project_name))
+    return quart.redirect(quart.url_for("root_project_view", 
project_name=name))
diff --git a/atr/routes/release.py b/atr/routes/release.py
index bd05912..8e35103 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -143,8 +143,10 @@ 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_query = sqlmodel.select(models.Release).where(
-                models.Release.storage_key == task.task_args["release_key"]
+            release_query = (
+                sqlmodel.select(models.Release)
+                .where(models.Release.storage_key == 
task.task_args["release_key"])
+                .options(db.select_in_load_nested(models.Release.product, 
models.Product.project, models.Project.pmc))
             )
             release_result = await db_session.execute(release_query)
             release = release_result.scalar_one_or_none()
@@ -195,7 +197,7 @@ async def root_release_vote() -> response.Response | str:
             raise base.ASFQuartException("Release has no associated PMC", 
errorcode=400)
 
         # Prepare email recipient
-        email_to = f"{mailing_list}@{release.pmc.project_name}.apache.org"
+        email_to = f"{mailing_list}@{release.pmc.name}.apache.org"
 
         # Create a task for vote initiation
         task = models.Task(
@@ -237,11 +239,11 @@ def generate_vote_email_preview(release: models.Release) 
-> str:
     # Get PMC details
     if release.pmc is None:
         raise base.ASFQuartException("Release has no associated PMC", 
errorcode=400)
-    pmc_name = release.pmc.project_name
+    pmc_name = release.pmc.name
     pmc_display = release.pmc.display_name
 
     # Get product information
-    product_name = release.product_line.product_name if release.product_line 
else "Unknown"
+    product_name = release.product.product_name if release.product else 
"Unknown"
 
     # Create email subject
     subject = f"[VOTE] Release Apache {pmc_display} {product_name} {version}"
diff --git a/atr/tasks/bulk.py b/atr/tasks/bulk.py
index 537d5d4..846c5d4 100644
--- a/atr/tasks/bulk.py
+++ b/atr/tasks/bulk.py
@@ -66,7 +66,7 @@ class Args:
     max_concurrent: int
 
     @staticmethod
-    def from_list(args: list[str]) -> "Args":
+    def from_dict(args: dict[str, Any]) -> "Args":
         """Parse command line arguments."""
         _LOGGER.debug(f"Parsing arguments: {args}")
 
@@ -74,12 +74,12 @@ class Args:
             _LOGGER.error(f"Invalid number of arguments: {len(args)}, expected 
6")
             raise ValueError("Invalid number of arguments")
 
-        release_key = args[0]
-        base_url = args[1]
-        file_types = args[2]
-        require_sigs = args[3]
-        max_depth = args[4]
-        max_concurrent = args[5]
+        release_key = args["release_key"]
+        base_url = args["base_url"]
+        file_types = args["file_types"]
+        require_sigs = args["require_sigs"]
+        max_depth = args["max_depth"]
+        max_concurrent = args["max_concurrent"]
 
         _LOGGER.debug(
             f"Extracted values - release_key: {release_key}, base_url: 
{base_url}, "
@@ -285,7 +285,7 @@ def database_progress_percentage_calculate(progress: 
tuple[int, int] | None) ->
     return percentage
 
 
-def download(args: list[str]) -> tuple[task.Status, str | None, tuple[Any, 
...]]:
+async def download(args: dict[str, Any]) -> tuple[task.Status, str | None, 
tuple[Any, ...]]:
     """Download bulk package from URL."""
     # Returns (status, error, result)
     # This is the main task entry point, called by worker.py
@@ -293,7 +293,7 @@ def download(args: list[str]) -> tuple[task.Status, str | 
None, tuple[Any, ...]]
     _LOGGER.info(f"Starting bulk download task with args: {args}")
     try:
         _LOGGER.debug("Delegating to download_core function")
-        status, error, result = download_core(args)
+        status, error, result = await download_core(args)
         _LOGGER.info(f"Download completed with status: {status}")
         return status, error, result
     except Exception as e:
@@ -302,33 +302,32 @@ def download(args: list[str]) -> tuple[task.Status, str | 
None, tuple[Any, ...]]
         return task.FAILED, str(e), ({"message": f"Error: {e}", "progress": 
0},)
 
 
-def download_core(args_list: list[str]) -> tuple[task.Status, str | None, 
tuple[Any, ...]]:
+async def download_core(args_dict: dict[str, Any]) -> tuple[task.Status, str | 
None, tuple[Any, ...]]:
     """Download bulk package from URL."""
     _LOGGER.info("Starting download_core")
     try:
-        _LOGGER.debug(f"Parsing arguments: {args_list}")
-        args = Args.from_list(args_list)
+        _LOGGER.debug(f"Parsing arguments: {args_dict}")
+        args = Args.from_dict(args_dict)
         _LOGGER.info(f"Args parsed successfully: 
release_key={args.release_key}, base_url={args.base_url}")
 
         # Create async resources
         _LOGGER.debug("Creating async queue and semaphore")
         queue: asyncio.Queue[str] = asyncio.Queue()
         semaphore = asyncio.Semaphore(args.max_concurrent)
-        loop = asyncio.get_event_loop()
 
         # Start URL crawling
-        loop.run_until_complete(database_message(f"Crawling URLs from 
{args.base_url}"))
+        await database_message(f"Crawling URLs from {args.base_url}")
 
         _LOGGER.info("Starting artifact_urls coroutine")
-        signatures, artifacts = loop.run_until_complete(artifact_urls(args, 
queue, semaphore))
+        signatures, artifacts = await artifact_urls(args, queue, semaphore)
         _LOGGER.info(f"Found {len(signatures)} signatures and {len(artifacts)} 
artifacts")
 
         # Update progress for download phase
-        loop.run_until_complete(database_message(f"Found {len(artifacts)} 
artifacts to download"))
+        await database_message(f"Found {len(artifacts)} artifacts to download")
 
         # Download artifacts
         _LOGGER.info("Starting artifacts_download coroutine")
-        artifacts_downloaded = 
loop.run_until_complete(artifacts_download(artifacts, semaphore))
+        artifacts_downloaded = await artifacts_download(artifacts, semaphore)
         files_downloaded = len(artifacts_downloaded)
 
         # Return a result dictionary
@@ -349,12 +348,13 @@ def download_core(args_list: list[str]) -> 
tuple[task.Status, str | None, tuple[
 
     except Exception as e:
         _LOGGER.exception(f"Error in download_core: {e}")
+        base_url = args_dict["base_url"] if len(args_dict) > 1 else "unknown 
URL"
         return (
             task.FAILED,
             str(e),
             (
                 {
-                    "message": f"Failed to download from {args_list[1] if 
len(args_list) > 1 else 'unknown URL'}",
+                    "message": f"Failed to download from {base_url}",
                     "progress": 0,
                 },
             ),
diff --git a/atr/tasks/signature.py b/atr/tasks/signature.py
index 881dd29..5fe1566 100644
--- a/atr/tasks/signature.py
+++ b/atr/tasks/signature.py
@@ -46,11 +46,9 @@ def _check_core(pmc_name: str, artifact_path: str, 
signature_path: str) -> dict[
     # Query only the signing keys associated with this PMC
     # TODO: Rename create_sync_db_session to create_session_sync
     # Using isinstance does not work here, with pyright
-    project_name = db.validate_instrumented_attribute(models.PMC.project_name)
+    name = db.validate_instrumented_attribute(models.PMC.name)
     with db.create_sync_db_session() as session:
-        statement = (
-            
sql.select(models.PublicSigningKey).join(models.PMCKeyLink).join(models.PMC).where(project_name
 == pmc_name)
-        )
+        statement = 
sql.select(models.PublicSigningKey).join(models.PMCKeyLink).join(models.PMC).where(name
 == pmc_name)
         result = session.execute(statement)
         public_keys = [key.ascii_armored_key for key in result.scalars().all()]
 
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 1865c0b..866d375 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -176,9 +176,9 @@ def initiate_core(args_list: list[str]) -> 
tuple[task.Status, str | None, tuple[
             _LOGGER.error(error_msg)
             return task.FAILED, error_msg, tuple()
 
-        pmc_name = release.pmc.project_name
+        pmc_name = release.pmc.name
         pmc_display = release.pmc.display_name
-        product_name = release.product_line.product_name if 
release.product_line else "Unknown"
+        product_name = release.product.product_name if release.product else 
"Unknown"
         version = release.version
 
         # Create email subject
diff --git a/atr/templates/candidate-create.html 
b/atr/templates/candidate-create.html
index 90c9fd0..8b4e490 100644
--- a/atr/templates/candidate-create.html
+++ b/atr/templates/candidate-create.html
@@ -40,8 +40,8 @@
                 class="mb-2 form-select"
                 required>
           <option value="">Select a project...</option>
-          {% for pmc in user_pmcs|sort(attribute='project_name') %}
-            <option value="{{ pmc.project_name }}">{{ pmc.display_name 
}}</option>
+          {% for pmc in user_pmcs|sort(attribute='name') %}
+            <option value="{{ pmc.name }}">{{ pmc.display_name }}</option>
           {% endfor %}
         </select>
         {% if not user_pmcs %}
diff --git a/atr/templates/candidate-review.html 
b/atr/templates/candidate-review.html
index 459316c..f0a094c 100644
--- a/atr/templates/candidate-review.html
+++ b/atr/templates/candidate-review.html
@@ -42,7 +42,7 @@
             <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_line.product_name if release.product_line else "unknown" 
}}</span>
+            <span class="candidate-meta-item">Product: {{ 
release.product.product_name if release.product 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">
@@ -69,7 +69,7 @@
           <tr>
             <th>Name</th>
             <td>
-              {{ format_artifact_name(release.pmc.project_name, 
release.product_line.product_name if release.product_line else "unknown", 
release.version, release.pmc.is_podling) }}
+              {{ format_artifact_name(release.pmc.name, 
release.product.product_name if release.product else "unknown", 
release.version, release.pmc.is_podling) }}
             </td>
           </tr>
           <tr>
diff --git a/atr/templates/keys-add.html b/atr/templates/keys-add.html
index f67ae64..543c501 100644
--- a/atr/templates/keys-add.html
+++ b/atr/templates/keys-add.html
@@ -143,13 +143,13 @@
       <div class="form-group">
         <label>Associate with projects:</label>
         <div class="pmc-checkboxes">
-          {% for pmc in user_pmcs|sort(attribute='project_name') %}
+          {% for pmc in user_pmcs|sort(attribute='name') %}
             <div class="checkbox-item">
               <input type="checkbox"
-                     id="pmc_{{ pmc.project_name }}"
+                     id="pmc_{{ pmc.name }}"
                      name="selected_pmcs"
-                     value="{{ pmc.project_name }}" />
-              <label for="pmc_{{ pmc.project_name }}">{{ pmc.display_name 
}}</label>
+                     value="{{ pmc.name }}" />
+              <label for="pmc_{{ pmc.name }}">{{ pmc.display_name }}</label>
             </div>
           {% endfor %}
         </div>
diff --git a/atr/templates/keys-review.html b/atr/templates/keys-review.html
index d372e15..30e27a3 100644
--- a/atr/templates/keys-review.html
+++ b/atr/templates/keys-review.html
@@ -198,7 +198,7 @@
                   <th>Associated projects</th>
                   <td>
                     {% if key.pmcs %}
-                      {{ key.pmcs|map(attribute='project_name') |join(', ') }}
+                      {{ key.pmcs|map(attribute='name') |join(', ') }}
                     {% else %}
                       No projects associated
                     {% endif %}
diff --git a/atr/templates/package-add.html b/atr/templates/package-add.html
index 05f94d1..655dfe3 100644
--- a/atr/templates/package-add.html
+++ b/atr/templates/package-add.html
@@ -144,7 +144,7 @@
               {% for release in releases %}
                 <option value="{{ release.storage_key }}"
                         {% if release.storage_key == selected_release 
%}selected{% endif %}>
-                  {{ release.pmc.display_name }} - {{ 
release.product_line.product_name if release.product_line else "unknown" }} - 
{{ release.version }}
+                  {{ release.pmc.display_name }} - {{ 
release.product.product_name if release.product else "unknown" }} - {{ 
release.version }}
                 </option>
               {% endfor %}
             </select>
@@ -253,7 +253,7 @@
               {% for release in releases %}
                 <option value="{{ release.storage_key }}"
                         {% if release.storage_key == selected_release 
%}selected{% endif %}>
-                  {{ release.pmc.display_name }} - {{ 
release.product_line.product_name if release.product_line else "unknown" }} - 
{{ release.version }}
+                  {{ release.pmc.display_name }} - {{ 
release.product.product_name if release.product else "unknown" }} - {{ 
release.version }}
                 </option>
               {% endfor %}
             </select>
diff --git a/atr/templates/project-directory.html 
b/atr/templates/project-directory.html
index 9cf85de..6c7404f 100644
--- a/atr/templates/project-directory.html
+++ b/atr/templates/project-directory.html
@@ -79,7 +79,7 @@
   <div class="pmc-grid">
     {% for pmc in projects %}
       <div class="pmc-card"
-           onclick="location.href='{{ url_for("root_project_view", 
project_name=pmc.project_name) }}';">
+           onclick="location.href='{{ url_for("root_project_view", 
project_name=pmc.name) }}';">
         <div class="pmc-name">{{ pmc.display_name }}</div>
         <div class="pmc-stats">
           <div class="stat-item">
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index ee95c6e..6842ee5 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -170,8 +170,9 @@
   <div class="card-header">
     <h3>Voting Policy</h3>
     <div class="actions">
+      <!-- TODO: This is a PMC, not a project -->
       <a class="add-link"
-         href="{{ url_for('root_project_voting_policy_add', 
project_name=project.project_name) }}"><i class="fa-solid fa-plus"></i></a>
+         href="{{ url_for('root_project_voting_policy_add', 
project_name=project.name) }}"><i class="fa-solid fa-plus"></i></a>
     </div>
     <!--    <div class="card-meta"></div>-->
     <div class="card-body">
diff --git a/atr/templates/release-bulk.html b/atr/templates/release-bulk.html
index 059460a..b969005 100644
--- a/atr/templates/release-bulk.html
+++ b/atr/templates/release-bulk.html
@@ -163,7 +163,7 @@
         <span>→</span>
         <span>{{ release.pmc.display_name }}</span>
         <span>→</span>
-        <span>{{ release.product_line.product_name if release.product_line 
else "Unknown product" }}</span>
+        <span>{{ release.product.product_name if release.product else "Unknown 
product" }}</span>
         <span>→</span>
         <span>{{ release.version }}</span>
       {% endif %}
diff --git a/atr/templates/release-vote.html b/atr/templates/release-vote.html
index 3beb5ed..f28b178 100644
--- a/atr/templates/release-vote.html
+++ b/atr/templates/release-vote.html
@@ -11,11 +11,6 @@
 {% block stylesheets %}
   {{ super() }}
   <style>
-      .vote-container {
-          max-width: 1000px;
-          margin: 0 auto;
-      }
-
       .form-group {
           margin-bottom: 1.5rem;
       }
@@ -124,95 +119,93 @@
 {% endblock stylesheets %}
 
 {% block content %}
-  <div class="vote-container">
-    <h1>Start release vote</h1>
-
-    <div class="release-info">
-      <h3>
-        {{ release.pmc.display_name }} - {{ release.product_line.product_name 
if release.product_line else "Unknown" }} {{ release.version }}
-      </h3>
-      <p>Initiating a vote for this release candidate will prepare an email to 
be sent to the appropriate mailing list.</p>
-    </div>
+  <h1>Start release vote</h1>
 
-    <div class="warning-text">
-      <strong>Note:</strong> This feature is currently in development. The 
form below only sends email to a test account.
-    </div>
+  <div class="release-info">
+    <h3>
+      {{ release.pmc.display_name }} - {{ release.product.product_name if 
release.product else "Unknown" }} {{ release.version }}
+    </h3>
+    <p>Initiating a vote for this release candidate will prepare an email to 
be sent to the appropriate mailing list.</p>
+  </div>
 
-    <form method="post"
-          action="{{ url_for('root_release_vote') }}"
-          class="striking">
-      <input type="hidden" name="release_key" value="{{ release.storage_key 
}}" />
-
-      <table class="form-table">
-        <tbody>
-          <tr>
-            <th>
-              <label>Send vote email to:</label>
-            </th>
-            <td>
-              <div class="radio-group">
-                <div class="radio-option">
-                  <input type="radio" id="list_dev" name="mailing_list" 
value="dev" checked />
-                  <label for="list_dev">dev@{{ release.pmc.project_name 
}}.apache.org</label>
-                </div>
-                <div class="radio-option">
-                  <input type="radio" id="list_private" name="mailing_list" 
value="private" />
-                  <label for="list_private">private@{{ 
release.pmc.project_name }}.apache.org</label>
-                </div>
-              </div>
-            </td>
-          </tr>
-
-          <tr>
-            <th>
-              <label for="vote_duration">Vote duration:</label>
-            </th>
-            <td>
-              <select id="vote_duration" name="vote_duration" 
class="form-select">
-                <option value="72">72 hours (minimum)</option>
-                <option value="120">5 days</option>
-                <option value="168">7 days</option>
-              </select>
-            </td>
-          </tr>
-
-          <tr>
-            <th>
-              <label for="gpg_key_id">Your GPG key ID:</label>
-            </th>
-            <td>
-              <input type="text"
-                     id="gpg_key_id"
-                     name="gpg_key_id"
-                     class="form-control"
-                     placeholder="e.g., 0x1A2B3C4D" />
-            </td>
-          </tr>
-
-          <tr>
-            <th>
-              <label for="commit_hash">Commit hash:</label>
-            </th>
-            <td>
-              <input type="text"
-                     id="commit_hash"
-                     name="commit_hash"
-                     class="form-control"
-                     placeholder="Git commit hash used for this release" />
-            </td>
-          </tr>
-        </tbody>
-      </table>
-
-      <div class="form-group">
-        <label class="preview-header">Email Preview:</label>
-        <div class="email-preview">{{ email_preview }}</div>
-      </div>
-
-      <div class="form-actions">
-        <button type="submit" class="submit-button">Prepare Vote Email</button>
-        <a href="{{ url_for('root_candidate_review') }}" 
class="cancel-link">Cancel</a>
-      </div>
-    </form>
+  <div class="warning-text">
+    <strong>Note:</strong> This feature is currently in development. The form 
below only sends email to a test account.
   </div>
+
+  <form method="post"
+        action="{{ url_for('root_release_vote') }}"
+        class="striking">
+    <input type="hidden" name="release_key" value="{{ release.storage_key }}" 
/>
+
+    <table class="form-table">
+      <tbody>
+        <tr>
+          <th>
+            <label>Send vote email to:</label>
+          </th>
+          <td>
+            <div class="radio-group">
+              <div class="radio-option">
+                <input type="radio" id="list_dev" name="mailing_list" 
value="dev" checked />
+                <label for="list_dev">dev@{{ release.pmc.name 
}}.apache.org</label>
+              </div>
+              <div class="radio-option">
+                <input type="radio" id="list_private" name="mailing_list" 
value="private" />
+                <label for="list_private">private@{{ release.pmc.name 
}}.apache.org</label>
+              </div>
+            </div>
+          </td>
+        </tr>
+
+        <tr>
+          <th>
+            <label for="vote_duration">Vote duration:</label>
+          </th>
+          <td>
+            <select id="vote_duration" name="vote_duration" 
class="form-select">
+              <option value="72">72 hours (minimum)</option>
+              <option value="120">5 days</option>
+              <option value="168">7 days</option>
+            </select>
+          </td>
+        </tr>
+
+        <tr>
+          <th>
+            <label for="gpg_key_id">Your GPG key ID:</label>
+          </th>
+          <td>
+            <input type="text"
+                   id="gpg_key_id"
+                   name="gpg_key_id"
+                   class="form-control"
+                   placeholder="e.g., 0x1A2B3C4D" />
+          </td>
+        </tr>
+
+        <tr>
+          <th>
+            <label for="commit_hash">Commit hash:</label>
+          </th>
+          <td>
+            <input type="text"
+                   id="commit_hash"
+                   name="commit_hash"
+                   class="form-control"
+                   placeholder="Git commit hash used for this release" />
+          </td>
+        </tr>
+      </tbody>
+    </table>
+
+    <div class="form-group">
+      <label class="preview-header">Email Preview:</label>
+      <div class="email-preview">{{ email_preview }}</div>
+    </div>
+
+    <div class="form-actions">
+      <button type="submit" class="submit-button">Prepare Vote Email</button>
+      <a href="{{ url_for('root_candidate_review') }}" 
class="cancel-link">Cancel</a>
+    </div>
+  </form>
 {% endblock content %}
diff --git a/atr/worker.py b/atr/worker.py
index a94bad4..2369a20 100644
--- a/atr/worker.py
+++ b/atr/worker.py
@@ -201,6 +201,7 @@ async def _task_process(task_id: int, task_type: str, 
task_args: str) -> None:
         # TODO: We should use a decorator to register these automatically
         dict_task_handlers = {
             "verify_archive_integrity": archive.check_integrity,
+            "package_bulk_download": bulk.download,
         }
         # TODO: These are synchronous
         # We plan to convert these to async dict handlers
@@ -211,7 +212,6 @@ async def _task_process(task_id: int, task_type: str, 
task_args: str) -> None:
             "verify_license_headers": license.check_headers,
             "verify_rat_license": rat.check_licenses,
             "generate_cyclonedx_sbom": sbom.generate_cyclonedx,
-            "package_bulk_download": bulk.download,
             "mailtest_send": mailtest.send,
             "vote_initiate": vote.initiate,
         }
diff --git a/docs/plan.html b/docs/plan.html
index 2125724..c6c447f 100644
--- a/docs/plan.html
+++ b/docs/plan.html
@@ -165,6 +165,7 @@
 <li>Ensure that release managers are made aware of SBOM quality and contents 
in the UI</li>
 <li>Add ability to upload existing SBOMs</li>
 <li>Add ability to validate uploaded SBOMs</li>
+<li><a 
href="https://github.com/apache/tooling-trusted-release/issues/8";>Export data 
through the Transparency Exchange API</a></li>
 </ul>
 </li>
 </ol>
diff --git a/docs/plan.md b/docs/plan.md
index 8df95fb..88ca3f4 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -122,6 +122,7 @@ These tasks are dependent on the task scheduler above.
    - Ensure that release managers are made aware of SBOM quality and contents 
in the UI
    - Add ability to upload existing SBOMs
    - Add ability to validate uploaded SBOMs
+   - [Export data through the Transparency Exchange 
API](https://github.com/apache/tooling-trusted-release/issues/8)
 
 ## Advanced RC validation
 


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

Reply via email to