You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/03/20 15:18:32 UTC
(tooling-trusted-release) branch main updated: Add a page giving instructions to upload using rsync
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 a0b0c2c Add a page giving instructions to upload using rsync
a0b0c2c is described below
commit a0b0c2cc6e2da57a0f88f6b21be9508265a9ba6a
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Mar 20 17:18:11 2025 +0200
Add a page giving instructions to upload using rsync
---
atr/blueprints/admin/__init__.py | 4 +-
atr/blueprints/admin/admin.py | 6 +-
atr/blueprints/admin/templates/data-browser.html | 2 +-
atr/db/__init__.py | 13 +-
atr/db/models.py | 64 ++++++++-
atr/db/service.py | 12 +-
atr/mail.py | 4 +-
atr/routes/__init__.py | 2 +-
atr/routes/candidate.py | 18 ++-
atr/routes/dev.py | 4 +-
atr/routes/docs.py | 4 +-
atr/routes/download.py | 18 +--
atr/routes/files.py | 159 +++++++++++++++++++++++
atr/routes/keys.py | 7 +-
atr/routes/package.py | 68 +++++-----
atr/routes/projects.py | 4 +-
atr/routes/release.py | 34 +++--
atr/routes/vote_policy.py | 4 +-
atr/server.py | 12 +-
atr/static/css/bootstrap.custom.css | 4 +
atr/tasks/bulk.py | 14 +-
atr/tasks/vote.py | 16 +--
atr/templates/candidate-review.html | 16 +--
atr/templates/files-add.html | 129 ++++++++++++++++++
atr/templates/includes/sidebar.html | 4 +
atr/templates/package-add.html | 21 +--
atr/templates/package-check.html | 6 +-
atr/templates/project-directory.html | 2 +-
atr/templates/release-vote.html | 2 +-
bootstrap/custom.scss | 5 +
30 files changed, 510 insertions(+), 148 deletions(-)
diff --git a/atr/blueprints/admin/__init__.py b/atr/blueprints/admin/__init__.py
index 6b4371c..ae91fdc 100644
--- a/atr/blueprints/admin/__init__.py
+++ b/atr/blueprints/admin/__init__.py
@@ -19,11 +19,11 @@
from typing import Final
+import quart
+
import asfquart.auth as auth
import asfquart.base as base
import asfquart.session as session
-import quart
-
import atr.util as util
BLUEPRINT: Final = quart.Blueprint("admin", __name__, url_prefix="/admin", template_folder="templates")
diff --git a/atr/blueprints/admin/admin.py b/atr/blueprints/admin/admin.py
index 120a6bd..61ce3ab 100644
--- a/atr/blueprints/admin/admin.py
+++ b/atr/blueprints/admin/admin.py
@@ -22,12 +22,12 @@ from collections.abc import Callable, Mapping
from typing import Any
import aiofiles.os
-import asfquart.base as base
-import asfquart.session as session
import httpx
import quart
import werkzeug.wrappers.response as response
+import asfquart.base as base
+import asfquart.session as session
import atr.blueprints.admin as admin
import atr.datasources.apache as apache
import atr.db as db
@@ -149,7 +149,7 @@ async def admin_data(model: str = "Committee") -> str:
# Fallback for models without dict() method
record_dict = {
"id": getattr(record, "id", None),
- "storage_key": getattr(record, "storage_key", None),
+ "name": getattr(record, "name", None),
}
for key in record.__dict__:
if not key.startswith("_"):
diff --git a/atr/blueprints/admin/templates/data-browser.html b/atr/blueprints/admin/templates/data-browser.html
index 0932431..eca4a86 100644
--- a/atr/blueprints/admin/templates/data-browser.html
+++ b/atr/blueprints/admin/templates/data-browser.html
@@ -80,7 +80,7 @@
{% for record in records %}
<div class="record">
<div class="record-header">
- <h3>{{ record.get('id', record.get('storage_key', 'Unknown ID') ) }}</h3>
+ <h3>{{ record.get("id", record.get("name", "Unknown ID")) }}</h3>
<span class="record-meta">{{ model }}</span>
</div>
<pre>{{ record | tojson(indent=2) }}</pre>
diff --git a/atr/db/__init__.py b/atr/db/__init__.py
index b3478b1..cf3f39f 100644
--- a/atr/db/__init__.py
+++ b/atr/db/__init__.py
@@ -38,7 +38,6 @@ if TYPE_CHECKING:
from datetime import datetime
import asfquart.base as base
-
from atr.db.models import ReleasePhase, ReleaseStage, TaskStatus, VoteEntry
_LOGGER: Final = logging.getLogger(__name__)
@@ -168,7 +167,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
signature_sha3: Opt[str] = NotSet,
uploaded: Opt[bool] = NotSet,
bytes_size: Opt[int] = NotSet,
- release_key: Opt[str] = NotSet,
+ release_name: Opt[str] = NotSet,
_release: bool = False,
_tasks: bool = False,
_release_project: bool = False,
@@ -190,8 +189,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
query = query.where(models.Package.uploaded == uploaded)
if is_defined(bytes_size):
query = query.where(models.Package.bytes_size == bytes_size)
- if is_defined(release_key):
- query = query.where(models.Package.release_key == release_key)
+ if is_defined(release_name):
+ query = query.where(models.Package.release_name == release_name)
if _release:
query = query.options(select_in_load(models.Package.release))
if _tasks:
@@ -284,7 +283,7 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
def release(
self,
- storage_key: Opt[str] = NotSet,
+ name: Opt[str] = NotSet,
stage: Opt[ReleaseStage] = NotSet,
phase: Opt[ReleasePhase] = NotSet,
created: Opt[datetime] = NotSet,
@@ -302,8 +301,8 @@ class Session(sqlalchemy.ext.asyncio.AsyncSession):
) -> Query[models.Release]:
query = sqlmodel.select(models.Release)
- if is_defined(storage_key):
- query = query.where(models.Release.storage_key == storage_key)
+ if is_defined(name):
+ query = query.where(models.Release.name == name)
if is_defined(stage):
query = query.where(models.Release.stage == stage)
if is_defined(phase):
diff --git a/atr/db/models.py b/atr/db/models.py
index df2ff89..9845966 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -17,6 +17,7 @@
"""The data models to be persisted in the database."""
+# NOTE: We can't use symbolic annotations here because sqlmodel doesn't support them
# from __future__ import annotations
import datetime
@@ -25,8 +26,11 @@ from typing import Any
import pydantic
import sqlalchemy
+import sqlalchemy.event as event
import sqlmodel
+import atr.db as db
+
class UserRole(str, enum.Enum):
COMMITTEE_MEMBER = "committee_member"
@@ -117,6 +121,7 @@ class Committee(sqlmodel.SQLModel, table=True):
class Project(sqlmodel.SQLModel, table=True):
id: int = sqlmodel.Field(default=None, primary_key=True)
name: str = sqlmodel.Field(unique=True)
+ # TODO: Ideally full_name would be unique for str only, but that's complex
full_name: str | None = sqlmodel.Field(default=None)
# True if this a podling project
@@ -143,6 +148,30 @@ class Project(sqlmodel.SQLModel, table=True):
name = self.name if self.full_name is None else self.full_name
return f"{name} (podling)" if self.is_podling else name
+ @property
+ async def editable_releases(self) -> list["Release"]:
+ """Get the editable ongoing releases for the project."""
+ # TODO: Improve our interface to use in_ automatically for lists
+ editable_phases = [
+ ReleasePhase.RELEASE_CANDIDATE,
+ ReleasePhase.EVALUATE_CLAIMS,
+ ReleasePhase.RELEASE,
+ ]
+ query = (
+ sqlmodel.select(Release)
+ .where(
+ Release.project_id == self.id,
+ db.validate_instrumented_attribute(Release.phase).in_(editable_phases),
+ )
+ .order_by(db.validate_instrumented_attribute(Release.created).desc())
+ )
+
+ async with db.session() as data:
+ results = []
+ for release in (await data.execute(query)).all():
+ results.append(release[0])
+ return results
+
class DistributionChannel(sqlmodel.SQLModel, table=True):
id: int = sqlmodel.Field(default=None, primary_key=True)
@@ -175,7 +204,7 @@ class Package(sqlmodel.SQLModel, table=True):
bytes_size: int
# Many-to-one: A package belongs to one release
- release_key: str = sqlmodel.Field(foreign_key="release.storage_key")
+ release_name: str = sqlmodel.Field(foreign_key="release.name")
release: "Release" = sqlmodel.Relationship(back_populates="packages")
# One-to-many: A package can have multiple tasks
@@ -201,17 +230,35 @@ class ReleaseStage(str, enum.Enum):
class ReleasePhase(str, enum.Enum):
+ # [CANDIDATE]
+ # Step 1: The RC is received from external sources
RELEASE_CANDIDATE = "release_candidate"
+ # Step 2: The ATR website evaluates claims about the RC
EVALUATE_CLAIMS = "evaluate_claims"
+ # Step 3: The RC is distributed to project members for testing
DISTRIBUTE_TEST = "distribute_test"
+ # Step 4: The project members are voting on the RC
VOTE = "vote"
+ # Step 5: The project vote on the RC has passed
PASSES = "passes"
+
+ # [CURRENT]
+ # Step 1: The release files are being put in place
RELEASE = "release"
+ # Step 2: The release files are available but not yet announced
DISTRIBUTE = "distribute"
+ # Step 3: The release has been announced but not yet released[?]
+ # TODO: Need to check the meaning of this phase
ANNOUNCE_RELEASE = "announce_release"
+ # Step 4: The release has been announced
RELEASED = "released"
+
+ # [Other]
+ # An existing release is being imported from ASF SVN dist
MIGRATION = "migration"
+ # A release candidate has failed at any CANDIDATE stage
FAILED = "failed"
+ # A previously CURRENT release has been archived
ARCHIVED = "archived"
@@ -268,7 +315,9 @@ class Task(sqlmodel.SQLModel, table=True):
class Release(sqlmodel.SQLModel, table=True):
- storage_key: str = sqlmodel.Field(primary_key=True)
+ # We guarantee that "{project.name}-{version}" is unique
+ # Therefore we can use that for the name
+ name: str = sqlmodel.Field(primary_key=True, unique=True)
stage: ReleaseStage
phase: ReleasePhase
created: datetime.datetime
@@ -293,6 +342,11 @@ class Release(sqlmodel.SQLModel, table=True):
votes: list[VoteEntry] = sqlmodel.Field(default_factory=list, sa_column=sqlalchemy.Column(sqlalchemy.JSON))
+ # The combination of project_id and version must be unique
+ # Technically we want (project.name, version) to be unique
+ # But project.name is already unique, so project_id works as a proxy thereof
+ __table_args__ = (sqlalchemy.UniqueConstraint("project_id", "version", name="unique_project_version"),)
+
@property
def committee(self) -> Committee | None:
"""Get the committee for the release."""
@@ -302,6 +356,12 @@ class Release(sqlmodel.SQLModel, table=True):
return project.committee
+@event.listens_for(Release, "before_insert")
+def check_release_name(_mapper: sqlalchemy.orm.Mapper, _connection: sqlalchemy.Connection, release: Release) -> None:
+ if release.name != f"{release.project.name}-{release.version}":
+ raise ValueError(f"Release name must be set to {release.project.name}-{release.version}")
+
+
class SSHKey(sqlmodel.SQLModel, table=True):
fingerprint: str = sqlmodel.Field(primary_key=True)
key: str
diff --git a/atr/db/service.py b/atr/db/service.py
index e97eb06..61f9d22 100644
--- a/atr/db/service.py
+++ b/atr/db/service.py
@@ -45,26 +45,26 @@ async def get_committees(session: sqlalchemy.ext.asyncio.AsyncSession | None = N
return committees
-async def get_release_by_key(storage_key: str) -> models.Release | None:
- """Get a release by its storage key."""
+async def get_release_by_name(name: str) -> models.Release | None:
+ """Get a release by its name."""
async with db.create_async_db_session() as db_session:
# Get the release
query = (
sqlmodel.select(models.Release)
- .where(models.Release.storage_key == storage_key)
+ .where(models.Release.name == name)
.options(db.select_in_load_nested(models.Release.project, models.Project.committee))
)
result = await db_session.execute(query)
return result.scalar_one_or_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."""
+def get_release_by_name_sync(name: str) -> models.Release | None:
+ """Synchronous version of get_release_by_name for use in background tasks."""
with db.create_sync_db_session() as session:
# Get the release
query = (
sqlmodel.select(models.Release)
- .where(models.Release.storage_key == storage_key)
+ .where(models.Release.name == name)
.options(db.select_in_load_nested(models.Release.project, models.Project.committee))
)
result = session.execute(query)
diff --git a/atr/mail.py b/atr/mail.py
index 371f831..2f78bb0 100644
--- a/atr/mail.py
+++ b/atr/mail.py
@@ -66,9 +66,9 @@ class VoteEvent:
"""Data class to represent a release vote event."""
def __init__(
- self, release_key: str, email_recipient: str, subject: str, body: str, vote_end: datetime.datetime
+ self, release_name: str, email_recipient: str, subject: str, body: str, vote_end: datetime.datetime
) -> None:
- self.release_key = release_key
+ self.release_name = release_name
self.email_recipient = email_recipient
self.subject = subject
self.body = body
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 603fe89..849f78f 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -25,10 +25,10 @@ from typing import Any, ParamSpec, TypeVar
import aiofiles
import aiofiles.os
-import asfquart
import quart
import werkzeug.datastructures as datastructures
+import asfquart
import atr.db.models as models
if asfquart.APP is ...:
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 49fb027..90dd49d 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -18,16 +18,15 @@
"""candidate.py"""
import datetime
-import secrets
-import asfquart
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
import quart
import werkzeug.wrappers.response as response
import wtforms
+import asfquart
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
import atr.db as db
import atr.db.models as models
import atr.routes as routes
@@ -109,9 +108,7 @@ async def release_add_post(session: session.ClientSession, request: quart.Reques
errorcode=403,
)
- # Generate a 128-bit random token for the release storage key
- # TODO: Perhaps we should call this the release_key instead
- storage_key = secrets.token_hex(16)
+ release_name = f"{project_name}-{version}"
project = await data.project(name=project_name).get()
if not project:
# Create a new project record
@@ -125,17 +122,18 @@ async def release_add_post(session: session.ClientSession, request: quart.Reques
# Create release record with project
release = models.Release(
- storage_key=storage_key,
+ name=release_name,
stage=models.ReleaseStage.CANDIDATE,
phase=models.ReleasePhase.RELEASE_CANDIDATE,
project_id=project.id,
+ project=project,
version=version,
created=datetime.datetime.now(datetime.UTC),
)
data.add(release)
# Redirect to the add package page with the storage token
- return quart.redirect(quart.url_for("root_package_add", storage_key=storage_key))
+ return quart.redirect(quart.url_for("root_package_add", name=release_name))
# Root functions
diff --git a/atr/routes/dev.py b/atr/routes/dev.py
index 597507b..765370d 100644
--- a/atr/routes/dev.py
+++ b/atr/routes/dev.py
@@ -16,12 +16,12 @@
# under the License.
+import quart
+
import asfquart as asfquart
import asfquart.auth as auth
import asfquart.base as base
import asfquart.session as session
-import quart
-
import atr.db as db
import atr.db.models as models
import atr.routes as routes
diff --git a/atr/routes/docs.py b/atr/routes/docs.py
index c4fb156..5d350a1 100644
--- a/atr/routes/docs.py
+++ b/atr/routes/docs.py
@@ -17,10 +17,10 @@
"""docs.py"""
-import asfquart as asfquart
-import asfquart.auth as auth
import quart
+import asfquart as asfquart
+import asfquart.auth as auth
import atr.routes as routes
diff --git a/atr/routes/download.py b/atr/routes/download.py
index 783bbfc..6ff0dbf 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -21,20 +21,20 @@ import pathlib
import aiofiles
import aiofiles.os
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
import quart
import werkzeug.wrappers.response as response
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
import atr.db as db
import atr.routes as routes
import atr.util as util
-@routes.app_route("/download/<release_key>/<artifact_sha3>")
+@routes.app_route("/download/<release_name>/<artifact_sha3>")
@auth.require(auth.Requirements.committer)
-async def root_download_artifact(release_key: str, artifact_sha3: str) -> response.Response | quart.Response:
+async def root_download_artifact(release_name: str, artifact_sha3: str) -> response.Response | quart.Response:
"""Download an artifact file."""
# TODO: This function is very similar to the signature download function
# We should probably extract the common code into a helper function
@@ -46,7 +46,7 @@ async def root_download_artifact(release_key: str, artifact_sha3: str) -> respon
# Find the package
package = await data.package(
artifact_sha3=artifact_sha3,
- release_key=release_key,
+ release_name=release_name,
_release_committee=True,
).get()
@@ -76,9 +76,9 @@ async def root_download_artifact(release_key: str, artifact_sha3: str) -> respon
)
-@routes.app_route("/download/signature/<release_key>/<signature_sha3>")
+@routes.app_route("/download/signature/<release_name>/<signature_sha3>")
@auth.require(auth.Requirements.committer)
-async def root_download_signature(release_key: str, signature_sha3: str) -> quart.Response | response.Response:
+async def root_download_signature(release_name: str, signature_sha3: str) -> quart.Response | response.Response:
"""Download a signature file."""
web_session = await session.read()
if (web_session is None) or (web_session.uid is None):
@@ -87,7 +87,7 @@ async def root_download_signature(release_key: str, signature_sha3: str) -> quar
async with db.session() as data:
# Find the package that has this signature
package = await data.package(
- signature_sha3=signature_sha3, release_key=release_key, _release_committee=True
+ signature_sha3=signature_sha3, release_name=release_name, _release_committee=True
).get()
if not package:
await quart.flash("Signature not found", "error")
diff --git a/atr/routes/files.py b/atr/routes/files.py
new file mode 100644
index 0000000..afb97e8
--- /dev/null
+++ b/atr/routes/files.py
@@ -0,0 +1,159 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""package.py"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any, NoReturn, Protocol, TypeVar
+
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
+import quart
+
+import atr.db as db
+import atr.db.models as models
+import atr.routes as routes
+
+if TYPE_CHECKING:
+ from collections.abc import Awaitable, Callable
+
+R = TypeVar("R", covariant=True)
+
+
+# This is the type of functions to which we apply @committer_get
+# In other words, functions which accept CommitterSession as their first arg
+class CommitterRouteHandler(Protocol[R]):
+ """Protocol for @committer_get decorated functions."""
+
+ __name__: str
+ __doc__: str | None
+
+ def __call__(self, session: CommitterSession, *args: Any, **kwargs: Any) -> Awaitable[R]: ...
+
+
+class CommitterSession:
+ """Session with extra information about committers."""
+
+ def __init__(self, web_session: session.ClientSession) -> None:
+ self._session = web_session
+
+ def __getattr__(self, name: str) -> Any:
+ # TODO: Not type safe, should subclass properly if possible
+ # For example, we can access session.no_such_attr and the type checkers won't notice
+ return getattr(self._session, name)
+
+ @property
+ def host(self) -> str:
+ request_host = quart.request.host
+ if ":" in request_host:
+ domain, port = request_host.split(":")
+ # Could be an IPv6 address, so need to check whether port is a valid integer
+ if port.isdigit():
+ return domain
+ return request_host
+
+ @property
+ async def user_projects(self) -> list[Any]:
+ user_projects: list[models.Project] = []
+ async with db.session() as data:
+ projects = await data.project(_committee=True, _releases=True).all()
+ for p in projects:
+ if p.committee is None:
+ continue
+ if (self.uid in p.committee.committee_members) or (self.uid in p.committee.committers):
+ user_projects.append(p)
+
+ return user_projects
+
+ @property
+ async def user_releases(self) -> list[Any]:
+ user_releases: list[models.Release] = []
+ async with db.session() as data:
+ # TODO: We're limiting this to candidates
+ # We should either call this user_candidate_releases, or change the query
+ releases = await data.release(stage=models.ReleaseStage.CANDIDATE, _project=True, _committee=True).all()
+ user_releases = []
+ for r in releases:
+ if r.committee is None:
+ continue
+ if (self.uid in r.committee.committee_members) or (self.uid in r.committee.committers):
+ user_releases.append(r)
+
+ return user_releases
+
+
+# This is the type of functions to which we apply @app_route
+# In other words, functions which accept no session
+class RouteHandler(Protocol[R]):
+ """Protocol for @app_route decorated functions."""
+
+ __name__: str
+ __doc__: str | None
+
+ def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[R]: ...
+
+
+def _authentication_failed() -> NoReturn:
+ """Handle authentication failure with an exception."""
+ # NOTE: This is a separate function to fix a problem with analysis flow in mypy
+ raise base.ASFQuartException("Not authenticated", errorcode=401)
+
+
+# This decorator is an adaptor between @committer_get and @app_route functions
+def committer_get(path: str) -> Callable[[CommitterRouteHandler[R]], RouteHandler[R]]:
+ """Decorator for committer GET routes that provides an enhanced session object."""
+
+ def decorator(func: CommitterRouteHandler[R]) -> RouteHandler[R]:
+ async def wrapper(*args: Any, **kwargs: Any) -> R:
+ web_session = await session.read()
+ if web_session is None:
+ _authentication_failed()
+
+ enhanced_session = CommitterSession(web_session)
+ return await func(enhanced_session, *args, **kwargs)
+
+ # Set the name before applying decorators
+ wrapper.__name__ = func.__name__
+ wrapper.__doc__ = func.__doc__
+
+ # Apply decorators in reverse order
+ decorated = auth.require(auth.Requirements.committer)(wrapper)
+ decorated = routes.app_route(path, methods=["GET"])(decorated)
+
+ return decorated
+
+ return decorator
+
+
+def number_of_release_files(release: models.Release) -> int:
+ """Return the number of files in the release."""
+ # TODO: Return the number of files in the release
+ return 0
+
+
+@committer_get("/files/add")
+async def root_files_add(session: CommitterSession) -> str:
+ """Show a page to allow the user to rsync files to editable releases."""
+ return await quart.render_template(
+ "files-add.html",
+ asf_id=session.uid,
+ projects=await session.user_projects,
+ server_domain=session.host,
+ number_of_release_files=number_of_release_files,
+ )
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 97b7024..04930f8 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -29,15 +29,16 @@ import shutil
import tempfile
from collections.abc import AsyncGenerator, Sequence
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
import cryptography.hazmat.primitives.serialization as serialization
import gnupg
import quart
import werkzeug.wrappers.response as response
import wtforms
+import asfquart as asfquart
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
import atr.db as db
import atr.db.models as models
import atr.routes as routes
diff --git a/atr/routes/package.py b/atr/routes/package.py
index c514b9f..38314f3 100644
--- a/atr/routes/package.py
+++ b/atr/routes/package.py
@@ -28,13 +28,13 @@ from collections.abc import Sequence
import aiofiles
import aiofiles.os
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
import quart
import werkzeug.datastructures as datastructures
import werkzeug.wrappers.response as response
+import asfquart.auth as auth
+import asfquart.base as base
+import asfquart.session as session
import atr.db as db
import atr.db.models as models
import atr.routes as routes
@@ -112,7 +112,7 @@ async def package_add_artifact_info_get(
async def package_add_session_process(
data: db.Session,
- release_key: str,
+ release_name: str,
artifact_file: datastructures.FileStorage,
checksum_file: datastructures.FileStorage | None,
signature_file: datastructures.FileStorage | None,
@@ -120,7 +120,7 @@ async def package_add_session_process(
"""Helper function for package_add_post."""
# First check for duplicates by filename
- duplicate = await data.package(release_key=release_key, filename=util.unwrap(artifact_file.filename)).get()
+ duplicate = await data.package(release_name=release_name, filename=util.unwrap(artifact_file.filename)).get()
if duplicate:
raise routes.FlashError("This release artifact has already been uploaded")
@@ -168,8 +168,8 @@ async def package_add_validate(
# TODO: Check that the submitter is a committer of the project
- release_key = form.get("release_key")
- if (not release_key) or (not isinstance(release_key, str)):
+ release_name = form.get("release_name")
+ if (not release_name) or (not isinstance(release_name, str)):
raise routes.FlashError("Release key is required")
# Get all uploaded files
@@ -191,17 +191,17 @@ async def package_add_validate(
if artifact_type not in ["source", "binary", "reproducible"]:
raise routes.FlashError("Invalid artifact type")
- return release_key, artifact_file, checksum_file, signature_file, artifact_type
+ return release_name, artifact_file, checksum_file, signature_file, artifact_type
-async def package_data_get(data: db.Session, artifact_sha3: str, release_key: str, session_uid: str) -> models.Package:
+async def package_data_get(data: db.Session, artifact_sha3: str, release_name: str, session_uid: str) -> models.Package:
"""Validate package deletion request and return the package if valid."""
# Get the package and its associated release
package = await data.package(artifact_sha3=artifact_sha3, _release_committee=True).demand(
routes.FlashError("Package not found")
)
- if package.release_key != release_key:
+ if package.release_name != release_name:
raise routes.FlashError("Invalid release key")
# Check permissions
@@ -221,8 +221,8 @@ async def package_add_bulk_validate(
form: datastructures.MultiDict, request: quart.Request
) -> tuple[str, str, list[str], bool, int]:
"""Validate bulk package addition form data."""
- release_key = form.get("release_key")
- if (not release_key) or (not isinstance(release_key, str)):
+ release_name = form.get("release_name")
+ if (not release_name) or (not isinstance(release_name, str)):
raise routes.FlashError("Release key is required")
url = form.get("url")
@@ -254,13 +254,13 @@ async def package_add_bulk_validate(
except (TypeError, ValueError):
raise routes.FlashError("Maximum depth must be between 1 and 10 inclusive")
- return release_key, url, file_types, require_signatures, max_depth
+ return release_name, url, file_types, require_signatures, max_depth
async def package_add_single_post(form: datastructures.MultiDict, request: quart.Request) -> response.Response:
"""Process single package upload submission."""
try:
- release_key, artifact_file, checksum_file, signature_file, artifact_type = await package_add_validate(request)
+ release_name, artifact_file, checksum_file, signature_file, artifact_type = await package_add_validate(request)
except routes.FlashError as e:
logging.exception("FlashError:")
await quart.flash(f"{e!s}", "error")
@@ -277,7 +277,7 @@ async def package_add_single_post(form: datastructures.MultiDict, request: quart
try:
try:
artifact_sha3, artifact_size, artifact_sha512, signature_sha3 = await package_add_session_process(
- data, release_key, artifact_file, checksum_file, signature_file
+ data, release_name, artifact_file, checksum_file, signature_file
)
except routes.FlashError as e:
logging.exception("FlashError:")
@@ -291,7 +291,7 @@ async def package_add_single_post(form: datastructures.MultiDict, request: quart
filename=artifact_file.filename,
signature_sha3=signature_sha3,
sha512=artifact_sha512,
- release_key=release_key,
+ release_name=release_name,
uploaded=datetime.datetime.now(datetime.UTC),
bytes_size=artifact_size,
)
@@ -308,7 +308,7 @@ async def package_add_single_post(form: datastructures.MultiDict, request: quart
async def package_add_bulk_post(form: datastructures.MultiDict, request: quart.Request) -> response.Response:
"""Process bulk package URL submission."""
try:
- release_key, url, file_types, require_signatures, max_depth = await package_add_bulk_validate(form, request)
+ release_name, url, file_types, require_signatures, max_depth = await package_add_bulk_validate(form, request)
except routes.FlashError as e:
logging.exception("FlashError:")
await quart.flash(f"{e!s}", "error")
@@ -322,7 +322,7 @@ async def package_add_bulk_post(form: datastructures.MultiDict, request: quart.R
status=models.TaskStatus.QUEUED,
task_type="package_bulk_download",
task_args={
- "release_key": release_key,
+ "release_name": release_name,
"base_url": url,
"file_types": file_types,
"require_sigs": require_signatures,
@@ -356,8 +356,8 @@ async def root_package_add() -> response.Response | str:
else:
return await package_add_single_post(form, quart.request)
- # Get the storage_key from the query parameters (if redirected from create)
- storage_key = quart.request.args.get("storage_key")
+ # Get the release name from the query parameters (if redirected from create)
+ release_name = quart.request.args.get("name")
# Get all releases where the user is a PMC member or committer of the associated PMC
async with db.session() as data:
@@ -379,7 +379,7 @@ async def root_package_add() -> response.Response | str:
"package-add.html",
asf_id=web_session.uid,
releases=user_releases,
- selected_release=storage_key,
+ selected_release=release_name,
)
@@ -395,12 +395,12 @@ async def root_package_check() -> str | response.Response:
if quart.request.method == "POST":
form = await routes.get_form(quart.request)
artifact_sha3 = form.get("artifact_sha3")
- release_key = form.get("release_key")
+ release_name = form.get("release_name")
else:
artifact_sha3 = quart.request.args.get("artifact_sha3")
- release_key = quart.request.args.get("release_key")
+ release_name = quart.request.args.get("release_name")
- if not artifact_sha3 or not release_key:
+ if not artifact_sha3 or not release_name:
await quart.flash("Missing required parameters", "error")
return quart.redirect(quart.url_for("root_candidate_review"))
@@ -408,7 +408,7 @@ async def root_package_check() -> str | response.Response:
async with data.begin():
# Get the package and verify permissions
try:
- package = await package_data_get(data, artifact_sha3, release_key, web_session.uid)
+ package = await package_data_get(data, artifact_sha3, release_name, web_session.uid)
except routes.FlashError as e:
logging.exception("FlashError:")
await quart.flash(str(e), "error")
@@ -430,7 +430,7 @@ async def root_package_check() -> str | response.Response:
await quart.flash(f"Added verification tasks for package {package.filename}", "success")
return quart.redirect(
- quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_key=release_key)
+ quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_name=release_name)
)
else:
# Get all tasks for this package for GET request
@@ -459,9 +459,9 @@ async def root_package_check_restart() -> response.Response:
form = await routes.get_form(quart.request)
artifact_sha3 = form.get("artifact_sha3")
- release_key = form.get("release_key")
+ release_name = form.get("release_name")
- if not artifact_sha3 or not release_key:
+ if not artifact_sha3 or not release_name:
await quart.flash("Missing required parameters", "error")
return quart.redirect(quart.url_for("root_candidate_review"))
@@ -469,7 +469,7 @@ async def root_package_check_restart() -> response.Response:
async with data.begin():
# Get the package and verify permissions
try:
- package = await package_data_get(data, artifact_sha3, release_key, web_session.uid)
+ package = await package_data_get(data, artifact_sha3, release_name, web_session.uid)
except routes.FlashError as e:
logging.exception("FlashError:")
await quart.flash(str(e), "error")
@@ -480,7 +480,7 @@ async def root_package_check_restart() -> response.Response:
if has_active_tasks:
await quart.flash("Cannot restart checks while tasks are still in progress", "error")
return quart.redirect(
- quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_key=release_key)
+ quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_name=release_name)
)
# Delete existing tasks
@@ -496,7 +496,7 @@ async def root_package_check_restart() -> response.Response:
await quart.flash("Package checks restarted successfully", "success")
return quart.redirect(
- quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_key=release_key)
+ quart.url_for("root_package_check", artifact_sha3=artifact_sha3, release_name=release_name)
)
@@ -510,16 +510,16 @@ async def root_package_delete() -> response.Response:
form = await routes.get_form(quart.request)
artifact_sha3 = form.get("artifact_sha3")
- release_key = form.get("release_key")
+ release_name = form.get("release_name")
- if not artifact_sha3 or not release_key:
+ if not artifact_sha3 or not release_name:
await quart.flash("Missing required parameters", "error")
return quart.redirect(quart.url_for("root_candidate_review"))
async with db.session() as data:
async with data.begin():
try:
- package = await package_data_get(data, artifact_sha3, release_key, web_session.uid)
+ package = await package_data_get(data, artifact_sha3, release_name, web_session.uid)
await routes.package_files_delete(package, pathlib.Path(util.get_release_storage_dir()))
await data.delete(package)
except routes.FlashError as e:
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index c1d962b..72bf39d 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -19,12 +19,12 @@
import http.client
-import asfquart.base as base
-import asfquart.session as session
import quart
import werkzeug.wrappers.response as response
import wtforms
+import asfquart.base as base
+import asfquart.session as session
import atr.db as db
import atr.db.models as models
import atr.routes as routes
diff --git a/atr/routes/release.py b/atr/routes/release.py
index d9e5565..fbc8557 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -21,13 +21,13 @@ import logging
import logging.handlers
import pathlib
+import quart
+import werkzeug.wrappers.response as response
+
import asfquart
import asfquart.auth as auth
import asfquart.base as base
import asfquart.session as session
-import quart
-import werkzeug.wrappers.response as response
-
import atr.db as db
import atr.db.models as models
import atr.db.service as service
@@ -41,11 +41,9 @@ if asfquart.APP is ...:
# Release functions
-async def release_delete_validate(data: db.Session, release_key: str, session_uid: str) -> models.Release:
+async def release_delete_validate(data: db.Session, release_name: str, session_uid: str) -> models.Release:
"""Validate release deletion request and return the release if valid."""
- release = await data.release(storage_key=release_key, _committee=True).demand(
- routes.FlashError("Release not found")
- )
+ release = await data.release(name=release_name, _committee=True).demand(routes.FlashError("Release not found"))
# Check permissions
if release.committee:
@@ -75,16 +73,16 @@ async def root_release_delete() -> response.Response:
raise base.ASFQuartException("Not authenticated", errorcode=401)
form = await routes.get_form(quart.request)
- release_key = form.get("release_key")
+ release_name = form.get("release_name")
- if not release_key:
+ if not release_name:
await quart.flash("Missing required parameters", "error")
return quart.redirect(quart.url_for("root_candidate_review"))
async with db.session() as data:
async with data.begin():
try:
- release = await release_delete_validate(data, release_key, web_session.uid)
+ release = await release_delete_validate(data, release_name, web_session.uid)
await release_files_delete(release, pathlib.Path(util.get_release_storage_dir()))
await data.delete(release)
except routes.FlashError as e:
@@ -127,8 +125,8 @@ async def release_bulk_status(task_id: int) -> str | response.Response:
release = None
# Debug print the task.task_args using the logger
logging.debug(f"Task args: {task.task_args}")
- if task.task_args and isinstance(task.task_args, dict) and ("release_key" in task.task_args):
- release = await data.release(storage_key=task.task_args["release_key"], _committee=True).get()
+ if task.task_args and isinstance(task.task_args, dict) and ("release_name" in task.task_args):
+ release = await data.release(name=task.task_args["release_name"], _committee=True).get()
# Check whether the user has permission to view this task
# Either they're a PMC member or committer for the release's PMC
@@ -151,19 +149,19 @@ async def root_release_vote() -> response.Response | str:
if web_session is None:
raise base.ASFQuartException("Not authenticated", errorcode=401)
- release_key = quart.request.args.get("release_key", "")
+ release_name = quart.request.args.get("release_name", "")
form = None
if quart.request.method == "POST":
form = await routes.get_form(quart.request)
- release_key = form.get("release_key", "")
+ release_name = form.get("release_name", "")
- if not release_key:
+ if not release_name:
await quart.flash("No release key provided", "error")
return quart.redirect(quart.url_for("root_candidate_review"))
- release = await service.get_release_by_key(release_key)
+ release = await service.get_release_by_name(release_name)
if release is None:
- await quart.flash(f"Release with key {release_key} not found", "error")
+ await quart.flash(f"Release with key {release_name} not found", "error")
return quart.redirect(quart.url_for("root_candidate_review"))
# If POST, process the form and create a vote_initiate task
@@ -185,7 +183,7 @@ async def root_release_vote() -> response.Response | str:
status=models.TaskStatus.QUEUED,
task_type="vote_initiate",
task_args=[
- release_key,
+ release_name,
email_to,
vote_duration,
gpg_key_id,
diff --git a/atr/routes/vote_policy.py b/atr/routes/vote_policy.py
index 8d30d5c..f447235 100644
--- a/atr/routes/vote_policy.py
+++ b/atr/routes/vote_policy.py
@@ -17,12 +17,12 @@
"""vote_policy.py"""
-import asfquart.base as base
-import asfquart.session as session
import quart
import werkzeug.wrappers.response as response
import wtforms
+import asfquart.base as base
+import asfquart.session as session
import atr.db as db
import atr.routes as routes
import atr.util as util
diff --git a/atr/server.py b/atr/server.py
index 0355bc1..7efc8a0 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -22,15 +22,15 @@ import os
from collections.abc import Iterable
from typing import Any
-import asfquart
-import asfquart.base as base
-import asfquart.generics
-import asfquart.session
import blockbuster
import quart
import quart_schema
import werkzeug.routing as routing
+import asfquart
+import asfquart.base as base
+import asfquart.generics
+import asfquart.session
import atr.blueprints as blueprints
import atr.config as config
import atr.db as db
@@ -60,6 +60,7 @@ def register_routes(app: base.QuartApp) -> tuple[str, ...]:
import atr.routes.dev as dev
import atr.routes.docs as docs
import atr.routes.download as download
+ import atr.routes.files as files
import atr.routes.keys as keys
import atr.routes.package as package
import atr.routes.projects as projects
@@ -97,12 +98,13 @@ def register_routes(app: base.QuartApp) -> tuple[str, ...]:
dev.__name__,
docs.__name__,
download.__name__,
+ files.__name__,
keys.__name__,
package.__name__,
projects.__name__,
release.__name__,
- vote_policy.__name__,
root.__name__,
+ vote_policy.__name__,
)
diff --git a/atr/static/css/bootstrap.custom.css b/atr/static/css/bootstrap.custom.css
index e69cf84..24cb8bc 100644
--- a/atr/static/css/bootstrap.custom.css
+++ b/atr/static/css/bootstrap.custom.css
@@ -11449,6 +11449,10 @@ th {
font-weight: 525;
}
+.atr-cursor-pointer {
+ cursor: pointer;
+}
+
small, .small, .text-muted {
font-variation-settings: "opsz" 14;
}
diff --git a/atr/tasks/bulk.py b/atr/tasks/bulk.py
index a223215..c49d0f7 100644
--- a/atr/tasks/bulk.py
+++ b/atr/tasks/bulk.py
@@ -59,7 +59,7 @@ global_task_id: int | None = None
# TODO: Use a Pydantic model instead
@dataclasses.dataclass
class Args:
- release_key: str
+ release_name: str
base_url: str
file_types: list[str]
require_sigs: bool
@@ -75,7 +75,7 @@ class Args:
_LOGGER.error(f"Invalid number of arguments: {len(args)}, expected 6")
raise ValueError("Invalid number of arguments")
- release_key = args["release_key"]
+ release_name = args["release_name"]
base_url = args["base_url"]
file_types = args["file_types"]
require_sigs = args["require_sigs"]
@@ -83,13 +83,13 @@ class Args:
max_concurrent = args["max_concurrent"]
_LOGGER.debug(
- f"Extracted values - release_key: {release_key}, base_url: {base_url}, "
+ f"Extracted values - release_name: {release_name}, base_url: {base_url}, "
f"file_types: {file_types}, require_sigs: {require_sigs}, "
f"max_depth: {max_depth}, max_concurrent: {max_concurrent}"
)
- if not isinstance(release_key, str):
- _LOGGER.error(f"Release key must be a string, got {type(release_key)}")
+ if not isinstance(release_name, str):
+ _LOGGER.error(f"Release key must be a string, got {type(release_name)}")
raise ValueError("Release key must be a string")
if not isinstance(base_url, str):
_LOGGER.error(f"Base URL must be a string, got {type(base_url)}")
@@ -114,7 +114,7 @@ class Args:
_LOGGER.debug("All argument validations passed")
args_obj = Args(
- release_key=release_key,
+ release_name=release_name,
base_url=base_url,
file_types=file_types,
require_sigs=require_sigs,
@@ -309,7 +309,7 @@ async def download_core(args_dict: dict[str, Any]) -> tuple[models.TaskStatus, s
try:
_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}")
+ _LOGGER.info(f"Args parsed successfully: release_name={args.release_name}, base_url={args.base_url}")
# Create async resources
_LOGGER.debug("Creating async queue and semaphore")
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 1fc73b3..992f7fb 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -50,7 +50,7 @@ _LOGGER.info("Vote module imported")
class Args:
"""Arguments for the vote_initiate task."""
- release_key: str
+ release_name: str
email_to: str
vote_duration: str
gpg_key_id: str
@@ -66,7 +66,7 @@ class Args:
_LOGGER.error(f"Invalid number of arguments: {len(args)}, expected 6")
raise ValueError("Invalid number of arguments")
- release_key = args[0]
+ release_name = args[0]
email_to = args[1]
vote_duration = args[2]
gpg_key_id = args[3]
@@ -75,7 +75,7 @@ class Args:
# Type checking
for arg_name, arg_value in [
- ("release_key", release_key),
+ ("release_name", release_name),
("email_to", email_to),
("vote_duration", vote_duration),
("gpg_key_id", gpg_key_id),
@@ -89,7 +89,7 @@ class Args:
_LOGGER.debug("All argument validations passed")
args_obj = Args(
- release_key=release_key,
+ release_name=release_name,
email_to=email_to,
vote_duration=vote_duration,
gpg_key_id=gpg_key_id,
@@ -116,8 +116,8 @@ def initiate(args: list[str]) -> tuple[models.TaskStatus, str | None, tuple[Any,
def initiate_core(args_list: list[str]) -> tuple[models.TaskStatus, str | None, tuple[Any, ...]]:
"""Get arguments, create an email, and then send it to the recipient."""
+ import atr.db.service as service
import atr.mail
- from atr.db.service import get_release_by_key_sync
test_recipients = ["sbp"]
_LOGGER.info("Starting initiate_core")
@@ -140,9 +140,9 @@ def initiate_core(args_list: list[str]) -> tuple[models.TaskStatus, str | None,
_LOGGER.info(f"Args parsed successfully: {args}")
# Get the release information
- release = get_release_by_key_sync(args.release_key)
+ release = service.get_release_by_name_sync(args.release_name)
if not release:
- error_msg = f"Release with key {args.release_key} not found"
+ error_msg = f"Release with key {args.release_name} not found"
_LOGGER.error(error_msg)
return task.FAILED, error_msg, tuple()
@@ -222,7 +222,7 @@ Thanks,
# Create mail event with test recipient
# Use test account instead of actual PMC list
event = atr.mail.VoteEvent(
- release_key=args.release_key,
+ release_name=args.release_name,
email_recipient=test_recipient,
subject=subject,
body=body,
diff --git a/atr/templates/candidate-review.html b/atr/templates/candidate-review.html
index 9004b61..7cf1e02 100644
--- a/atr/templates/candidate-review.html
+++ b/atr/templates/candidate-review.html
@@ -42,11 +42,11 @@
</div>
<div class="d-flex gap-3 align-items-center pt-2">
<a class="btn btn-primary"
- href="{{ url_for('root_package_add', storage_key=release.storage_key) }}">Add package</a>
+ href="{{ url_for('root_package_add', release_name=release.name) }}">Add package</a>
<form method="post"
action="{{ url_for('root_release_delete') }}"
class="d-inline-block m-0">
- <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+ <input type="hidden" name="release_name" value="{{ release.name }}" />
<button type="submit"
class="btn btn-danger"
onclick="return confirm('Are you sure you want to delete this entire release? This will delete all packages and cannot be undone.')">
@@ -54,7 +54,7 @@
</button>
</form>
<a class="btn btn-primary"
- href="{{ url_for('root_release_vote', release_key=release.storage_key) }}">Start vote</a>
+ href="{{ url_for('root_release_vote', release_name=release.name) }}">Start vote</a>
</div>
</div>
</div>
@@ -95,7 +95,7 @@
<input type="hidden"
name="artifact_sha3"
value="{{ package.artifact_sha3 }}" />
- <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+ <input type="hidden" name="release_name" value="{{ release.name }}" />
<button type="submit" class="btn btn-primary">Check package</button>
</form>
{% else %}
@@ -115,7 +115,7 @@
{{ status_parts|join(", ") }} ({{ completed_tasks }}/{{ total_tasks }} {{ "Check" if total_tasks == 1 else "Checks" }} Complete)
{% if package.tasks %}
<div class="mt-3">
- <a href="{{ url_for('root_package_check', artifact_sha3=package.artifact_sha3, release_key=release.storage_key) }}"
+ <a href="{{ url_for('root_package_check', artifact_sha3=package.artifact_sha3, release_name=release.name) }}"
class="btn btn-primary">Show check status</a>
</div>
{% endif %}
@@ -126,10 +126,10 @@
<th>Downloads</th>
<td>
<div class="d-flex flex-wrap gap-2 align-items-center">
- <a href="{{ url_for('root_download_artifact', release_key=release.storage_key, artifact_sha3=package.artifact_sha3) }}"
+ <a href="{{ url_for('root_download_artifact', release_name=release.name, artifact_sha3=package.artifact_sha3) }}"
class="btn btn-success download-button">Download Artifact</a>
{% if package.signature_sha3 %}
- <a href="{{ url_for('root_download_signature', release_key=release.storage_key, signature_sha3=package.signature_sha3) }}"
+ <a href="{{ url_for('root_download_signature', release_name=release.name, signature_sha3=package.signature_sha3) }}"
class="btn btn-success download-button">Download Signature</a>
{% endif %}
<a href="{{ url_for('root_docs_verify', filename=package.filename, artifact_sha3=package.artifact_sha3, sha512=package.sha512, has_signature='true' if package.signature_sha3 else 'false') }}"
@@ -146,7 +146,7 @@
<input type="hidden"
name="artifact_sha3"
value="{{ package.artifact_sha3 }}" />
- <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+ <input type="hidden" name="release_name" value="{{ release.name }}" />
<button type="submit"
class="btn btn-danger"
onclick="return confirm('Are you sure you want to delete this package?')">
diff --git a/atr/templates/files-add.html b/atr/templates/files-add.html
new file mode 100644
index 0000000..f2e77f3
--- /dev/null
+++ b/atr/templates/files-add.html
@@ -0,0 +1,129 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Add files ~ ATR
+{% endblock title %}
+
+{% block description %}
+ Add files to editable ongoing releases using rsync.
+{% endblock description %}
+
+{% block content %}
+ <h1>Add files</h1>
+ <p class="intro">
+ Welcome, <strong>{{ asf_id }}</strong>! This page allows you to manage files for editable ongoing releases across your projects using rsync.
+ <ul>
+ <li>Projects can have multiple ongoing releases simultaneously</li>
+ <li>
+ An ongoing release is only editable during specific phases of its lifecycle, and frozen otherwise:
+ <ul class="mb-0">
+ <li>Candidate releases are editable until submitted for voting</li>
+ <li>Approved releases are editable until they've been officially announced</li>
+ </ul>
+ </li>
+ <li>You can only create a new release if you are a member of the project's committee</li>
+ </ul>
+ </p>
+
+ <h2>Projects</h2>
+ <div class="row row-cols-1 row-cols-md-2 g-4 mb-5">
+ {% for project in projects %}
+ {% set editable_releases = project.editable_releases %}
+
+ {# Show card for creating a new release if allowed #}
+ {% if asf_id in project.committee.committee_members %}
+ <div class="col">
+ <div class="card h-100">
+ <div class="card-body position-relative">
+ <div class="position-absolute top-0 end-0 m-2">
+ <span class="badge bg-primary">Potential</span>
+ </div>
+ <h5 class="card-title">{{ project.display_name }}</h5>
+ {% if project.committee %}
+ <h6 class="card-subtitle mb-2 text-muted">{{ project.committee.display_name }}</h6>
+ {% endif %}
+ <p class="card-text">
+ {% if editable_releases|length > 0 %}
+ This project already has ongoing releases:
+ {% for release in editable_releases %}<a href="#{{ release.name }}">{{ release.version }}</a>;{% endfor %}
+ to create another one, use the command below.
+ {% else %}
+ This project does not have an editable ongoing release.
+ To create one and add files, use the command below.
+ {% endif %}
+ </p>
+ </div>
+ <pre class="card-footer bg-transparent border-top-0 small">
+rsync -av -e 'ssh -p 2222' your/files/ \
+ {{ asf_id }}@{{ server_domain }}:/{{ project.name }}/VERSION/
+ </pre>
+ </div>
+ </div>
+ {% else %}
+ <div class="col">
+ <div class="card h-100">
+ <div class="card-body position-relative">
+ <div class="position-absolute top-0 end-0 m-2">
+ <span class="badge bg-secondary">Ask</span>
+ </div>
+ <h5 class="card-title">{{ project.display_name }}</h5>
+ {% if project.committee %}
+ <h6 class="card-subtitle mb-2 text-muted">{{ project.committee.display_name }}</h6>
+ {% endif %}
+ <p class="card-text">
+ {% if editable_releases|length > 0 %}
+ This project already has ongoing releases:
+ {% for release in editable_releases %}<a href="#{{ release.name }}">{{ release.version }}</a>;{% endfor %}
+ to create another one, you must be a member of the project's committee.
+ {% else %}
+ This project does not have an editable ongoing release.
+ To create one, you must be a member of the project's committee.
+ {% endif %}
+ </p>
+ </div>
+ </div>
+ </div>
+ {% endif %}
+ {% endfor %}
+ </div>
+
+ <h2>Editable ongoing releases</h2>
+ <div class="row row-cols-1 row-cols-md-2 g-4 mb-5">
+ {% set ns = namespace(has_releases=false) %}
+ {% for project in projects %}
+ {% for release in project.editable_releases %}
+ {% set ns.has_releases = true %}
+ <div class="col" id="{{ release.name }}">
+ <div class="card h-100">
+ <div class="card-body position-relative">
+ <div class="position-absolute top-0 end-0 m-2">
+ <span class="badge bg-success">Ongoing</span>
+ </div>
+ <h5 class="card-title">{{ project.display_name }} {{ release.version or "?" }}</h5>
+ {% if project.committee %}
+ <h6 class="card-subtitle mb-2 text-muted">{{ project.committee.display_name }}</h6>
+ {% endif %}
+ <p class="card-text">
+ {% if number_of_release_files(release) > 0 %}
+ This editable ongoing release has {{ number_of_release_files(release) }} file(s).
+ {% else %}
+ This editable ongoing release doesn't have any files yet.
+ {% endif %}
+ Use the command below to add or modify files in this release:
+ </p>
+ </div>
+ <pre class="card-footer bg-transparent border-top-0 small">
+rsync -av -e 'ssh -p 2222' your/files/ \
+ {{ asf_id }}@{{ server_domain }}:/{{ project.name }}/{{ release.version }}/
+ </pre>
+ </div>
+ </div>
+ {% endfor %}
+ {% endfor %}
+ {% if not ns.has_releases %}
+ <div class="col-12">
+ <div class="alert alert-info">There are currently no editable ongoing releases.</div>
+ </div>
+ {% endif %}
+ </div>
+{% endblock content %}
diff --git a/atr/templates/includes/sidebar.html b/atr/templates/includes/sidebar.html
index ead4f99..ce5f960 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -53,6 +53,10 @@
<a href="{{ url_for('root_package_add') }}"
{% if request.endpoint == 'root_package_add' %}class="active"{% endif %}>Add package</a>
</li>
+ <li>
+ <a href="{{ url_for('root_files_add') }}"
+ {% if request.endpoint == 'root_files_add' %}class="active"{% endif %}>Add files</a>
+ </li>
<li>
<a href="{{ url_for('root_candidate_review') }}"
{% if request.endpoint == 'root_candidate_review' %}class="active"{% endif %}>Review candidates</a>
diff --git a/atr/templates/package-add.html b/atr/templates/package-add.html
index 8745dec..e164465 100644
--- a/atr/templates/package-add.html
+++ b/atr/templates/package-add.html
@@ -21,14 +21,17 @@
<tbody>
<tr>
<th class="text-end pe-3 align-top">
- <label for="release_key">Release:</label>
+ <label for="release_name">Release:</label>
</th>
<td class="align-top">
- <select id="release_key" name="release_key" class="form-select mb-2" required>
+ <select id="release_name"
+ name="release_name"
+ class="form-select mb-2"
+ required>
<option value="">Select a release...</option>
{% for release in releases %}
- <option value="{{ release.storage_key }}"
- {% if release.storage_key == selected_release %}selected{% endif %}>
+ <option value="{{ release.name }}"
+ {% if release.name == selected_release %}selected{% endif %}>
{{ release.committee.display_name }} - {{ release.project.name if release.project else "unknown" }} - {{ release.version }}
</option>
{% endfor %}
@@ -144,17 +147,17 @@
<tbody>
<tr>
<th class="text-end pe-3 align-top">
- <label for="bulk_release_key">Release:</label>
+ <label for="bulk_release_name">Release:</label>
</th>
<td class="align-top">
- <select id="bulk_release_key"
- name="release_key"
+ <select id="bulk_release_name"
+ name="release_name"
class="form-select mb-2"
required>
<option value="">Select a release...</option>
{% for release in releases %}
- <option value="{{ release.storage_key }}"
- {% if release.storage_key == selected_release %}selected{% endif %}>
+ <option value="{{ release.name }}"
+ {% if release.name == selected_release %}selected{% endif %}>
{{ release.committee.display_name }} - {{ release.project.name if release.project else "unknown" }} - {{ release.version }}
</option>
{% endfor %}
diff --git a/atr/templates/package-check.html b/atr/templates/package-check.html
index 5cf3e53..13f6a77 100644
--- a/atr/templates/package-check.html
+++ b/atr/templates/package-check.html
@@ -79,7 +79,7 @@
<input type="hidden"
name="artifact_sha3"
value="{{ package.artifact_sha3 }}" />
- <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+ <input type="hidden" name="release_name" value="{{ release.name }}" />
<button type="submit" class="btn btn-primary">Restart all checks</button>
</form>
{% endif %}
@@ -111,9 +111,9 @@
{% if task.result and task.result[0] is mapping and task.result|length == 1 %}
<details class="mt-2 p-2 bg-light rounded">
{% if task.error %}
- <summary class="cursor-pointer user-select-none p-2">Issue: {{ task.error }}</summary>
+ <summary class="atr-cursor-pointer user-select-none p-2">Issue: {{ task.error }}</summary>
{% else %}
- <summary class="cursor-pointer user-select-none p-2">View detailed results</summary>
+ <summary class="atr-cursor-pointer user-select-none p-2">View detailed results</summary>
{% endif %}
{% if task.task_type == 'verify_rat_license' and task.result[0] is mapping %}
diff --git a/atr/templates/project-directory.html b/atr/templates/project-directory.html
index 27deff8..2665ddc 100644
--- a/atr/templates/project-directory.html
+++ b/atr/templates/project-directory.html
@@ -22,7 +22,7 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for project in projects %}
<div class="col">
- <div class="card h-100 shadow-sm cursor-pointer hover-lift project-card"
+ <div class="card h-100 shadow-sm atr-cursor-pointer hover-lift project-card"
data-project-url="{{ url_for('root_project_view', name=project.name) }}">
<div class="card-body">
<h2 class="card-title fs-4 mb-3">{{ project.display_name }}</h2>
diff --git a/atr/templates/release-vote.html b/atr/templates/release-vote.html
index 1acb71c..d8f60c6 100644
--- a/atr/templates/release-vote.html
+++ b/atr/templates/release-vote.html
@@ -28,7 +28,7 @@
<form method="post"
class="striking py-4 px-5"
action="{{ url_for('root_release_vote') }}">
- <input type="hidden" name="release_key" value="{{ release.storage_key }}" />
+ <input type="hidden" name="release_name" value="{{ release.name }}" />
<div class="mb-4">
<div class="row mb-3 pb-3 border-bottom">
diff --git a/bootstrap/custom.scss b/bootstrap/custom.scss
index b122c1b..510d2d4 100644
--- a/bootstrap/custom.scss
+++ b/bootstrap/custom.scss
@@ -70,6 +70,11 @@ th {
@extend .w-25;
}
+// Not in Bootstrap
+.atr-cursor-pointer {
+ cursor: pointer;
+}
+
small, .text-muted {
font-variation-settings: "opsz" 14;
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org