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]