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 4067b04 Restructure the release storage to a phased system
4067b04 is described below
commit 4067b04e7a7fa82dac242ce2a754f906fa8c43a2
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Mar 24 19:58:46 2025 +0200
Restructure the release storage to a phased system
This commit replaces the separate hashed releases and rsync upload
areas with an area split into phases: candidate-draft for files being
prepared for vote, candidate-release for files being voted upon,
distributable-draft for files in the process of being finalised for
release, and distributable-release for approved and distributed
files. This commit updates all of the related functionality, including
uploads via browser and rsync, downloads, and file management. The UI
has also changed to reflect the new terminology. There is still some
hybridisation of the interface, and deprecated code.
---
atr/config.py | 2 +-
atr/db/models.py | 10 +-
atr/routes/__init__.py | 16 --
atr/routes/candidate.py | 3 +-
atr/routes/download.py | 95 ++---------
atr/routes/files.py | 136 +++++++++++----
atr/routes/package.py | 311 -----------------------------------
atr/routes/release.py | 36 ++--
atr/server.py | 6 +-
atr/ssh.py | 3 +-
atr/static/css/atr.css | 4 +
atr/tasks/rsync.py | 3 +-
atr/templates/candidate-review.html | 35 ----
atr/templates/files-add-project.html | 54 ++++++
atr/templates/files-add.html | 59 +++----
atr/templates/files-list.html | 6 +-
atr/templates/includes/sidebar.html | 16 +-
atr/user.py | 12 +-
atr/util.py | 31 +++-
poetry.lock | 14 +-
pyproject.toml | 1 +
21 files changed, 298 insertions(+), 555 deletions(-)
diff --git a/atr/config.py b/atr/config.py
index bd522d3..1186e8f 100644
--- a/atr/config.py
+++ b/atr/config.py
@@ -42,7 +42,7 @@ class AppConfig:
DEBUG = False
TEMPLATES_AUTO_RELOAD = False
USE_BLOCKBUSTER = False
- RELEASE_STORAGE_DIR = os.path.join(STATE_DIR, "releases")
+ PHASE_STORAGE_DIR = os.path.join(STATE_DIR, "phase")
SQLITE_DB_PATH = decouple.config("SQLITE_DB_PATH", default="/atr.db")
# Apache RAT configuration
diff --git a/atr/db/models.py b/atr/db/models.py
index a441317..3a41d3c 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -148,19 +148,19 @@ class Project(sqlmodel.SQLModel, table=True):
return name
@property
- async def editable_releases(self) -> list["Release"]:
- """Get the editable ongoing releases for the project."""
+ async def candidate_drafts(self) -> list["Release"]:
+ """Get the candidate drafts for the project."""
# TODO: Improve our interface to use in_ automatically for lists
- editable_phases = [
+ candidate_draft_phases = [
ReleasePhase.RELEASE_CANDIDATE,
ReleasePhase.EVALUATE_CLAIMS,
- ReleasePhase.RELEASE,
+ # ReleasePhase.RELEASE,
]
query = (
sqlmodel.select(Release)
.where(
Release.project_id == self.id,
-
db.validate_instrumented_attribute(Release.phase).in_(editable_phases),
+
db.validate_instrumented_attribute(Release.phase).in_(candidate_draft_phases),
)
.order_by(db.validate_instrumented_attribute(Release.created).desc())
)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 77e7bfa..862c904 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -18,7 +18,6 @@
import asyncio
import functools
import logging
-import pathlib
import time
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Final, ParamSpec, TypeVar
@@ -29,8 +28,6 @@ import asfquart
import quart
import werkzeug.datastructures as datastructures
-import atr.db.models as models
-
if asfquart.APP is ...:
raise RuntimeError("APP is not set")
@@ -297,16 +294,3 @@ async def get_form(request: quart.Request) ->
datastructures.MultiDict:
if blockbuster is not None:
blockbuster.activate()
return form
-
-
-async def package_files_delete(package: models.Package, uploads_path:
pathlib.Path) -> None:
- """Delete the artifact and signature files associated with a package."""
- if package.artifact_sha3:
- artifact_path = uploads_path / package.artifact_sha3
- if await aiofiles.os.path.exists(artifact_path):
- await aiofiles.os.remove(artifact_path)
-
- if package.signature_sha3:
- signature_path = uploads_path / package.signature_sha3
- if await aiofiles.os.path.exists(signature_path):
- await aiofiles.os.remove(signature_path)
diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 0e7cc5b..9d1cd4f 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -133,7 +133,8 @@ async def release_add_post(session: session.ClientSession,
request: quart.Reques
data.add(release)
# Redirect to the add package page with the storage token
- return quart.redirect(quart.url_for("root_package_add", name=release_name))
+ await quart.flash("Release candidate created successfully", "success")
+ return quart.redirect(quart.url_for("root_candidate_review"))
# Root functions
diff --git a/atr/routes/download.py b/atr/routes/download.py
index 037498a..89a7b8c 100644
--- a/atr/routes/download.py
+++ b/atr/routes/download.py
@@ -27,92 +27,33 @@ import asfquart.session as session
import quart
import werkzeug.wrappers.response as response
-import atr.db as db
import atr.routes as routes
import atr.util as util
[email protected]_route("/download/<release_name>/<artifact_sha3>")
[email protected]_route("/download/<phase>/<project>/<version>/<path>")
@auth.require(auth.Requirements.committer)
-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
+async def root_download(
+ phase: str, project: str, version: str, path: pathlib.Path
+) -> response.Response | quart.Response:
+ """Download a file from a release in any phase."""
web_session = await session.read()
if (web_session is None) or (web_session.uid is None):
raise base.ASFQuartException("Not authenticated", errorcode=401)
- async with db.session() as data:
- # Find the package
- package = await data.package(
- artifact_sha3=artifact_sha3,
- release_name=release_name,
- _release_committee=True,
- ).get()
+ # Check that path is relative
+ path = pathlib.Path(path)
+ if not path.is_relative_to(path.anchor):
+ raise routes.FlashError("Path must be relative")
- if not package:
- await quart.flash("Artifact not found", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
+ file_path = util.get_phase_dir() / phase / project / version / path
- # Check permissions
- if package.release and package.release.committee:
- if (web_session.uid not in
package.release.committee.committee_members) and (
- web_session.uid not in package.release.committee.committers
- ):
- await quart.flash("You don't have permission to download this
file", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
+ # Check that the file exists
+ if not await aiofiles.os.path.exists(file_path):
+ await quart.flash("File not found", "error")
+ return quart.redirect(quart.url_for("root_candidate_review"))
- # Construct file path
- file_path = pathlib.Path(util.get_release_storage_dir()) /
artifact_sha3
-
- # Check that the file exists
- if not await aiofiles.os.path.exists(file_path):
- await quart.flash("Artifact file not found", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
-
- # Send the file with original filename
- return await quart.send_file(
- file_path, as_attachment=True,
attachment_filename=package.filename, mimetype="application/octet-stream"
- )
-
-
[email protected]_route("/download/signature/<release_name>/<signature_sha3>")
[email protected](auth.Requirements.committer)
-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):
- raise base.ASFQuartException("Not authenticated", errorcode=401)
-
- async with db.session() as data:
- # Find the package that has this signature
- package = await data.package(
- signature_sha3=signature_sha3, release_name=release_name,
_release_committee=True
- ).get()
- if not package:
- await quart.flash("Signature not found", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
-
- # Check permissions
- if package.release and package.release.committee:
- if (web_session.uid not in
package.release.committee.committee_members) and (
- web_session.uid not in package.release.committee.committers
- ):
- await quart.flash("You don't have permission to download this
file", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
-
- # Construct file path
- file_path = pathlib.Path(util.get_release_storage_dir()) /
signature_sha3
-
- # Check that the file exists
- if not await aiofiles.os.path.exists(file_path):
- await quart.flash("Signature file not found", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
-
- # Send the file with original filename and .asc extension
- return await quart.send_file(
- file_path,
- as_attachment=True,
- attachment_filename=f"{package.filename}.asc",
- mimetype="application/pgp-signature",
- )
+ # Send the file with original filename
+ return await quart.send_file(
+ file_path, as_attachment=True, attachment_filename=path.name,
mimetype="application/octet-stream"
+ )
diff --git a/atr/routes/files.py b/atr/routes/files.py
index b659695..59eee8e 100644
--- a/atr/routes/files.py
+++ b/atr/routes/files.py
@@ -19,15 +19,20 @@
from __future__ import annotations
-import os
+import asyncio
+import logging
import pathlib
import re
from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypeVar
+import aiofiles
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 wtforms
import atr.analysis as analysis
import atr.config as config
@@ -79,17 +84,17 @@ class CommitterSession:
return request_host
@property
- async def user_editable_releases(self) -> list[Any]:
- return await user.editable_releases(self.uid,
user_projects=self._projects)
+ async def user_candidate_drafts(self) -> list[models.Release]:
+ return await user.candidate_drafts(self.uid,
user_projects=self._projects)
@property
- async def user_projects(self) -> list[Any]:
+ async def user_projects(self) -> list[models.Project]:
if self._projects is None:
self._projects = await user.projects(self.uid)
return self._projects
@property
- async def user_releases(self) -> list[Any]:
+ async def user_releases(self) -> list[models.Release]:
return await user.releases(self.uid)
@@ -104,6 +109,14 @@ class RouteHandler(Protocol[R]):
def __call__(self, *args: Any, **kwargs: Any) -> Awaitable[R]: ...
+class FilesAddOneForm(util.QuartFormTyped):
+ """Form for adding a single file to a release candidate."""
+
+ file_path = wtforms.StringField("File path (optional)",
validators=[wtforms.validators.Optional()])
+ file_data = wtforms.FileField("File",
validators=[wtforms.validators.InputRequired("File is required")])
+ submit = wtforms.SubmitField("Add file")
+
+
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
@@ -114,24 +127,23 @@ async def _number_of_release_files(release:
models.Release) -> int:
"""Return the number of files in the release."""
path_project = release.project.name
path_version = release.version
- path = os.path.join(_CONFIG.STATE_DIR, "rsync-files", path_project,
path_version)
+ path = util.get_candidate_draft_dir() / path_project / path_version
return len(await util.paths_recursive(path))
def _path_warnings_errors(
- paths: set[str], path: str, ext_artifact: str | None, ext_metadata: str |
None
+ paths: set[pathlib.Path], path: pathlib.Path, ext_artifact: str | None,
ext_metadata: str | None
) -> tuple[list[str], list[str]]:
# NOTE: This is important institutional logic
# TODO: We should probably move this to somewhere more important than a
routes module
warnings = []
errors = []
- filename = os.path.basename(path)
# The Release Distribution Policy specifically allows README and CHANGES,
etc.
# We assume that LICENSE and NOTICE are permitted also
- if filename == "KEYS":
+ if path.name == "KEYS":
errors.append("Please upload KEYS to ATR directly instead of using
rsync")
- elif path.startswith(".") or ("/." in path):
+ elif any(part.startswith(".") for part in path.parts):
# TODO: There is not a a policy for this
# We should enquire as to whether such a policy should be instituted
# We're forbidding dotfiles to catch accidental uploads of e.g. .git
or .htaccess
@@ -151,7 +163,9 @@ def _path_warnings_errors(
return warnings, errors
-def _path_warnings_errors_artifact(paths: set[str], path: str, ext_artifact:
str) -> tuple[list[str], list[str]]:
+def _path_warnings_errors_artifact(
+ paths: set[pathlib.Path], path: pathlib.Path, ext_artifact: str
+) -> tuple[list[str], list[str]]:
# We refer to the following authoritative policies:
# - Release Creation Process (RCP)
# - Release Distribution Policy (RDP)
@@ -160,24 +174,26 @@ def _path_warnings_errors_artifact(paths: set[str], path:
str, ext_artifact: str
errors: list[str] = []
# RDP says that .asc is required and one of .sha256 or .sha512
- if (path + ".asc") not in paths:
+ if path.with_suffix(path.suffix + ".asc") not in paths:
errors.append("Missing an .asc counterpart")
- no_sha256 = (path + ".sha256") not in paths
- no_sha512 = (path + ".sha512") not in paths
+ no_sha256 = path.with_suffix(path.suffix + ".sha256") not in paths
+ no_sha512 = path.with_suffix(path.suffix + ".sha512") not in paths
if no_sha256 and no_sha512:
errors.append("Missing a .sha256 or .sha512 counterpart")
return warnings, errors
-def _path_warnings_errors_metadata(paths: set[str], path: str, ext_metadata:
str) -> tuple[list[str], list[str]]:
+def _path_warnings_errors_metadata(
+ paths: set[pathlib.Path], path: pathlib.Path, ext_metadata: str
+) -> tuple[list[str], list[str]]:
# We refer to the following authoritative policies:
# - Release Creation Process (RCP)
# - Release Distribution Policy (RDP)
warnings: list[str] = []
errors: list[str] = []
- suffixes = set(pathlib.Path(path).suffixes)
+ suffixes = set(path.suffixes)
if ".md5" in suffixes:
# Forbidden by RCP, deprecated by RDP
@@ -200,7 +216,7 @@ def _path_warnings_errors_metadata(paths: set[str], path:
str, ext_metadata: str
warnings.append("The use of this metadata file is discouraged")
# If a metadata file is present, it must have an artifact counterpart
- artifact_path = path.removesuffix(ext_metadata)
+ artifact_path = path.with_name(path.name.removesuffix(ext_metadata))
if artifact_path not in paths:
errors.append("Missing an artifact counterpart")
@@ -208,7 +224,9 @@ def _path_warnings_errors_metadata(paths: set[str], path:
str, ext_metadata: str
# This decorator is an adaptor between @committer_get and @app_route functions
-def committer_get(path: str) -> Callable[[CommitterRouteHandler[R]],
RouteHandler[R]]:
+def committer_route(
+ path: str, methods: list[str] | None = None
+) -> Callable[[CommitterRouteHandler[R]], RouteHandler[R]]:
"""Decorator for committer GET routes that provides an enhanced session
object."""
def decorator(func: CommitterRouteHandler[R]) -> RouteHandler[R]:
@@ -226,20 +244,20 @@ def committer_get(path: str) ->
Callable[[CommitterRouteHandler[R]], RouteHandle
# Apply decorators in reverse order
decorated = auth.require(auth.Requirements.committer)(wrapper)
- decorated = routes.app_route(path, methods=["GET"])(decorated)
+ decorated = routes.app_route(path, methods=methods or
["GET"])(decorated)
return decorated
return decorator
-@committer_get("/files/add")
+@committer_route("/files/add")
async def root_files_add(session: CommitterSession) -> str:
- """Show a page to allow the user to rsync files to editable releases."""
+ """Show a page to allow the user to rsync files to candidate drafts."""
# Do them outside of the template rendering call to ensure order
- # The user_editable_releases call can use cached results from user_projects
+ # The user_candidate_drafts call can use cached results from user_projects
user_projects = await session.user_projects
- user_editable_releases = await session.user_editable_releases
+ user_candidate_drafts = await session.user_candidate_drafts
return await quart.render_template(
"files-add.html",
@@ -247,11 +265,73 @@ async def root_files_add(session: CommitterSession) ->
str:
projects=user_projects,
server_domain=session.host,
number_of_release_files=_number_of_release_files,
- editable_releases=user_editable_releases,
+ candidate_drafts=user_candidate_drafts,
+ )
+
+
+async def _add_one(
+ project_name: str,
+ version_name: str,
+ file_path: pathlib.Path | None,
+ file: datastructures.FileStorage,
+) -> None:
+ """Process and save the uploaded file."""
+ # Create target directory
+ target_dir = util.get_candidate_draft_dir() / project_name / version_name
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ # Use the original filename if no path is specified
+ if not file_path:
+ if not file.filename:
+ raise routes.FlashError("No filename provided")
+ file_path = pathlib.Path(file.filename)
+
+ # Save file to specified path
+ target_path = target_dir / file_path.relative_to(file_path.anchor)
+ target_path.parent.mkdir(parents=True, exist_ok=True)
+ async with aiofiles.open(target_path, "wb") as f:
+ while True:
+ chunk = await asyncio.to_thread(file.stream.read, 8192)
+ if not chunk:
+ break
+ await f.write(chunk)
+
+
+@committer_route("/files/add/<project_name>/<version_name>", methods=["GET",
"POST"])
+async def root_files_add_project(
+ session: CommitterSession, project_name: str, version_name: str
+) -> response.Response | str:
+ """Show a page to allow the user to add a single file to a candidate
draft."""
+ form = await FilesAddOneForm.create_form()
+ if await form.validate_on_submit():
+ try:
+ file_path = None
+ if isinstance(form.file_path.data, str):
+ file_path = pathlib.Path(form.file_path.data)
+ file_data = form.file_data.data
+ if not isinstance(file_data, datastructures.FileStorage):
+ raise routes.FlashError("Invalid file upload")
+
+ await _add_one(project_name, version_name, file_path, file_data)
+ await quart.flash("File added successfully", "success")
+ return quart.redirect(
+ quart.url_for("root_files_list", project_name=project_name,
version_name=version_name)
+ )
+ except Exception as e:
+ logging.exception("Error adding file:")
+ await quart.flash(f"Error adding file: {e!s}", "error")
+
+ return await quart.render_template(
+ "files-add-project.html",
+ asf_id=session.uid,
+ server_domain=session.host,
+ project_name=project_name,
+ version_name=version_name,
+ form=form,
)
-@committer_get("/files/list/<project_name>/<version_name>")
+@committer_route("/files/list/<project_name>/<version_name>")
async def root_files_list(session: CommitterSession, project_name: str,
version_name: str) -> str:
"""Show all the files in the rsync upload directory for a release."""
# Check that the user has access to the project
@@ -264,7 +344,7 @@ async def root_files_list(session: CommitterSession,
project_name: str, version_
base.ASFQuartException("Release does not exist", errorcode=404)
)
- base_path = os.path.join(_CONFIG.STATE_DIR, "rsync-files", project_name,
version_name)
+ base_path = util.get_candidate_draft_dir() / project_name / version_name
paths = await util.paths_recursive(base_path)
paths_set = set(paths)
path_templates = {}
@@ -282,12 +362,12 @@ async def root_files_list(session: CommitterSession,
project_name: str, version_
"template": None,
"substitutions": None,
}
- template, substitutions = analysis.filename_parse(path, elements)
+ template, substitutions = analysis.filename_parse(str(path), elements)
path_templates[path] = template
path_substitutions[path] =
analysis.substitutions_format(substitutions) or "none"
# Get artifacts and metadata
- search = re.search(analysis.extension_pattern(), path)
+ search = re.search(analysis.extension_pattern(), str(path))
ext_artifact = None
ext_metadata = None
if search:
diff --git a/atr/routes/package.py b/atr/routes/package.py
index 603ef48..a70d339 100644
--- a/atr/routes/package.py
+++ b/atr/routes/package.py
@@ -18,7 +18,6 @@
"""package.py"""
import asyncio
-import datetime
import hashlib
import logging
import logging.handlers
@@ -40,7 +39,6 @@ import atr.db as db
import atr.db.models as models
import atr.routes as routes
import atr.tasks as tasks
-import atr.util as util
async def file_hash_save(base_dir: pathlib.Path, file:
datastructures.FileStorage) -> tuple[str, int]:
@@ -86,115 +84,6 @@ async def file_hash_save(base_dir: pathlib.Path, file:
datastructures.FileStorag
# Package functions
-async def package_add_artifact_info_get(
- data: db.Session,
- uploads_path: pathlib.Path,
- artifact_file: datastructures.FileStorage,
-) -> tuple[str, str, int]:
- """Get artifact information during package addition process.
-
- Returns a tuple of (sha3_hash, sha512_hash, size) for the artifact file.
- Validates that the artifact hasn't already been uploaded to another
release.
- """
- # In a separate function to appease the complexity checker
- artifact_sha3, artifact_size = await file_hash_save(uploads_path,
artifact_file)
-
- # Check for duplicates by artifact_sha3 before proceeding
-
- duplicate = await data.package(artifact_sha3=artifact_sha3).get()
- if duplicate:
- # Remove the saved file since we won't be using it
- await aiofiles.os.remove(uploads_path / artifact_sha3)
- raise routes.FlashError("This exact file has already been uploaded to
another release")
-
- # Compute SHA-512 of the artifact for the package record
- return artifact_sha3, await util.compute_sha512(uploads_path /
artifact_sha3), artifact_size
-
-
-async def package_add_session_process(
- data: db.Session,
- release_name: str,
- artifact_file: datastructures.FileStorage,
- checksum_file: datastructures.FileStorage | None,
- signature_file: datastructures.FileStorage | None,
-) -> tuple[str, int, str, str | None]:
- """Helper function for package_add_post."""
-
- # First check for duplicates by filename
- 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")
-
- # Save files using their hashes as filenames
- uploads_path = pathlib.Path(util.get_release_storage_dir())
- try:
- artifact_sha3, artifact_sha512, artifact_size = await
package_add_artifact_info_get(
- data, uploads_path, artifact_file
- )
- except Exception as e:
- raise routes.FlashError(f"Error saving artifact file: {e!s}")
- # Note: "error" is not permitted past this point
- # Because we don't want to roll back saving the artifact
-
- # Validate checksum file if provided
- if checksum_file:
- try:
- # Read only the number of bytes required for the checksum
- bytes_required: int = len(artifact_sha512)
- checksum_content =
checksum_file.read(bytes_required).decode().strip()
- if checksum_content.lower() != artifact_sha512.lower():
- await quart.flash("Warning: Provided checksum does not match
computed SHA-512", "warning")
- except UnicodeDecodeError:
- await quart.flash("Warning: Could not read checksum file as text",
"warning")
- except Exception as e:
- await quart.flash(f"Warning: Error validating checksum file:
{e!s}", "warning")
-
- # Process signature file if provided
- signature_sha3 = None
- if signature_file and signature_file.filename:
- if not signature_file.filename.endswith(".asc"):
- await quart.flash("Warning: Signature file should have .asc
extension", "warning")
- try:
- signature_sha3, _ = await file_hash_save(uploads_path,
signature_file)
- except Exception as e:
- await quart.flash(f"Warning: Could not save signature file:
{e!s}", "warning")
-
- return artifact_sha3, artifact_size, artifact_sha512, signature_sha3
-
-
-async def package_add_validate(
- request: quart.Request,
-) -> tuple[str, datastructures.FileStorage, datastructures.FileStorage | None,
datastructures.FileStorage | None, str]:
- form = await routes.get_form(request)
-
- # TODO: Check that the submitter is a committer of the project
-
- 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
- files = await request.files
- artifact_file = files.get("release_artifact")
- checksum_file = files.get("release_checksum")
- signature_file = files.get("release_signature")
- if not isinstance(artifact_file, datastructures.FileStorage):
- raise routes.FlashError("Release artifact file is required")
- if checksum_file is not None and not isinstance(checksum_file,
datastructures.FileStorage):
- raise routes.FlashError("Problem with checksum file")
- if signature_file is not None and not isinstance(signature_file,
datastructures.FileStorage):
- raise routes.FlashError("Problem with signature file")
-
- # Get and validate artifact type
- artifact_type = form.get("artifact_type")
- if (not artifact_type) or (not isinstance(artifact_type, str)):
- raise routes.FlashError("Artifact type is required")
- if artifact_type not in ["source", "binary", "reproducible"]:
- raise routes.FlashError("Invalid 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_name:
str, session_uid: str) -> models.Package:
"""Validate package deletion request and return the package if valid."""
# Get the package and its associated release
@@ -218,172 +107,6 @@ async def package_data_get(data: db.Session,
artifact_sha3: str, release_name: s
# Release functions
-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_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")
- if (not url) or (not isinstance(url, str)):
- raise routes.FlashError("URL is required")
-
- # Validate URL format
- if not url.startswith(("http://", "https://")):
- raise routes.FlashError("URL must start with http:// or https://")
-
- # Get selected file types
- file_types = form.getlist("file_types")
- if not file_types:
- raise routes.FlashError("At least one file type must be selected")
-
- # Validate file types
- valid_types = {".tar.gz", ".tgz", ".zip", ".jar"}
- if not all(ft in valid_types for ft in file_types):
- raise routes.FlashError("Invalid file type selected")
-
- # Get require signatures flag
- require_signatures = bool(form.get("require_signatures"))
-
- # Get max depth
- try:
- max_depth = int(form.get("max_depth", "1"))
- if not 1 <= max_depth <= 10:
- raise ValueError()
- except (TypeError, ValueError):
- raise routes.FlashError("Maximum depth must be between 1 and 10
inclusive")
-
- 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_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")
- return quart.redirect(quart.url_for("root_package_add"))
- # This must come here to appease the type checker
- if artifact_file.filename is None:
- await quart.flash("Release artifact filename is required", "error")
- return quart.redirect(quart.url_for("root_package_add"))
-
- # Save files and create package record in one transaction
- async with db.session() as data:
- # Process and save the files
- async with data.begin():
- try:
- try:
- artifact_sha3, artifact_size, artifact_sha512,
signature_sha3 = await package_add_session_process(
- data, release_name, artifact_file, checksum_file,
signature_file
- )
- except routes.FlashError as e:
- logging.exception("FlashError:")
- await quart.flash(f"{e!s}", "error")
- return quart.redirect(quart.url_for("root_package_add"))
-
- # Create the package record
- package = models.Package(
- artifact_sha3=artifact_sha3,
- artifact_type=artifact_type,
- filename=artifact_file.filename,
- signature_sha3=signature_sha3,
- sha512=artifact_sha512,
- release_name=release_name,
- uploaded=datetime.datetime.now(datetime.UTC),
- bytes_size=artifact_size,
- )
- data.add(package)
-
- except Exception as e:
- await quart.flash(f"Error processing files: {e!s}", "error")
- return quart.redirect(quart.url_for("root_package_add"))
-
- # Otherwise redirect to review page
- return quart.redirect(quart.url_for("root_candidate_review"))
-
-
-async def package_add_bulk_post(form: datastructures.MultiDict, request:
quart.Request) -> response.Response:
- """Process bulk package URL submission."""
- try:
- 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")
- return quart.redirect(quart.url_for("root_package_add"))
-
- # Create a task for bulk downloading
- max_concurrency = 5
- async with db.session() as data:
- async with data.begin():
- task = models.Task(
- status=models.TaskStatus.QUEUED,
- task_type="package_bulk_download",
- task_args={
- "release_name": release_name,
- "base_url": url,
- "file_types": file_types,
- "require_sigs": require_signatures,
- "max_depth": max_depth,
- "max_concurrent": max_concurrency,
- },
- )
- data.add(task)
- # Flush to get the task ID
- await data.flush()
-
- await quart.flash("Started downloading packages from URL", "success")
- return quart.redirect(quart.url_for("release_bulk_status",
task_id=task.id))
-
-
[email protected]_route("/package/add", methods=["GET", "POST"])
[email protected](auth.Requirements.committer)
-async def root_package_add() -> response.Response | str:
- """Add package artifacts to an existing release."""
- web_session = await session.read()
- if web_session is None:
- raise base.ASFQuartException("Not authenticated", errorcode=401)
-
- # For POST requests, handle the form submission
- if quart.request.method == "POST":
- form = await routes.get_form(quart.request)
- form_type = form.get("form_type")
-
- if form_type == "bulk":
- return await package_add_bulk_post(form, quart.request)
- else:
- return await package_add_single_post(form, quart.request)
-
- # 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:
- # Load all the necessary relationships for the pmc property to work
- releases = await data.release(stage=models.ReleaseStage.CANDIDATE,
_committee=True).all()
-
- # Filter to only show releases for PMCs or PPMCs where the user is a
member or committer
- # Can we do this in sqlmodel using JSON container operators?
- user_releases = []
- for r in releases:
- if r.committee is None:
- continue
- # For PPMCs the "members" are stored in the committers field
- if (web_session.uid in r.committee.committee_members) or
(web_session.uid in r.committee.committers):
- user_releases.append(r)
-
- # For GET requests, show the form
- return await quart.render_template(
- "package-add.html",
- asf_id=web_session.uid,
- releases=user_releases,
- selected_release=release_name,
- )
-
-
@routes.app_route("/package/check", methods=["GET", "POST"])
@auth.require(auth.Requirements.committer)
async def root_package_check() -> str | response.Response:
@@ -501,40 +224,6 @@ async def root_package_check_restart() ->
response.Response:
)
[email protected]_route("/package/delete", methods=["POST"])
[email protected](auth.Requirements.committer)
-async def root_package_delete() -> response.Response:
- """Delete a package from a release candidate."""
- web_session = await session.read()
- if (web_session is None) or (web_session.uid is None):
- raise base.ASFQuartException("Not authenticated", errorcode=401)
-
- form = await routes.get_form(quart.request)
- artifact_sha3 = form.get("artifact_sha3")
- release_name = form.get("release_name")
-
- 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_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:
- logging.exception("FlashError:")
- await quart.flash(str(e), "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
- except Exception as e:
- await quart.flash(f"Error deleting files: {e!s}", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
-
- await quart.flash("Package deleted successfully", "success")
- return quart.redirect(quart.url_for("root_candidate_review"))
-
-
async def task_package_status_get(data: db.Session, artifact_sha3: str) ->
tuple[Sequence[models.Task], bool]:
"""
Get all tasks for a package and determine if any are still in progress.
diff --git a/atr/routes/release.py b/atr/routes/release.py
index ebb320e..6e04d42 100644
--- a/atr/routes/release.py
+++ b/atr/routes/release.py
@@ -19,8 +19,9 @@
import logging
import logging.handlers
-import pathlib
+import aiofiles.os
+import aioshutil
import asfquart
import asfquart.auth as auth
import asfquart.base as base
@@ -43,7 +44,9 @@ if asfquart.APP is ...:
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(name=release_name,
_committee=True).demand(routes.FlashError("Release not found"))
+ release = await data.release(name=release_name, _committee=True,
_packages=True).demand(
+ routes.FlashError("Release not found")
+ )
# Check permissions
if release.committee:
@@ -55,19 +58,10 @@ async def release_delete_validate(data: db.Session,
release_name: str, session_u
return release
-async def release_files_delete(release: models.Release, uploads_path:
pathlib.Path) -> None:
- """Delete all files associated with a release."""
- if not release.packages:
- return
-
- for package in release.packages:
- await routes.package_files_delete(package, uploads_path)
-
-
@routes.app_route("/release/delete", methods=["POST"])
@auth.require(auth.Requirements.committer)
async def root_release_delete() -> response.Response:
- """Delete a release and all its associated packages."""
+ """Delete a release and all its associated files."""
web_session = await session.read()
if (web_session is None) or (web_session.uid is None):
raise base.ASFQuartException("Not authenticated", errorcode=401)
@@ -82,17 +76,27 @@ async def root_release_delete() -> response.Response:
async with db.session() as data:
async with data.begin():
try:
+ # First validate and get the release info
release = await release_delete_validate(data, release_name,
web_session.uid)
- await release_files_delete(release,
pathlib.Path(util.get_release_storage_dir()))
+ project_name = release.project.name
+ version = release.version
+
+ # Delete all associated packages first
+ for package in release.packages:
+ await data.delete(package)
await data.delete(release)
+
except routes.FlashError as e:
logging.exception("FlashError:")
await quart.flash(str(e), "error")
return quart.redirect(quart.url_for("root_candidate_review"))
- except Exception as e:
- await quart.flash(f"Error deleting release: {e!s}", "error")
- return quart.redirect(quart.url_for("root_candidate_review"))
+ # except Exception as e:
+ # await quart.flash(f"Error deleting release: {e!s}", "error")
+ # return quart.redirect(quart.url_for("root_candidate_review"))
+ release_dir = util.get_candidate_draft_dir() / project_name / version
+ if await aiofiles.os.path.exists(release_dir):
+ await aioshutil.rmtree(release_dir)
await quart.flash("Release deleted successfully", "success")
return quart.redirect(quart.url_for("root_candidate_review"))
diff --git a/atr/server.py b/atr/server.py
index 0a37188..5c011d6 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -37,6 +37,7 @@ import atr.db as db
import atr.manager as manager
import atr.preload as preload
import atr.ssh as ssh
+import atr.util as util
# TODO: Technically this is a global variable
# We should probably find a cleaner way to do this
@@ -114,7 +115,10 @@ def app_dirs_setup(app_config: type[config.AppConfig]) ->
None:
raise RuntimeError(f"State directory not found:
{app_config.STATE_DIR}")
os.chdir(app_config.STATE_DIR)
print(f"Working directory changed to: {os.getcwd()}")
- os.makedirs(app_config.RELEASE_STORAGE_DIR, exist_ok=True)
+ util.get_candidate_draft_dir().mkdir(parents=True, exist_ok=True)
+ util.get_candidate_release_dir().mkdir(parents=True, exist_ok=True)
+ util.get_distributable_draft_dir().mkdir(parents=True, exist_ok=True)
+ util.get_distributable_release_dir().mkdir(parents=True, exist_ok=True)
def app_create_base(app_config: type[config.AppConfig]) -> base.QuartApp:
diff --git a/atr/ssh.py b/atr/ssh.py
index f902543..33585ff 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -32,6 +32,7 @@ import atr.config as config
import atr.db as db
import atr.db.models as models
import atr.user as user
+import atr.util as util
_LOGGER: Final = logging.getLogger(__name__)
_CONFIG: Final = config.get()
@@ -246,7 +247,7 @@ async def _command_validate(process:
asyncssh.SSHServerProcess) -> tuple[str, st
return fail("You must be a member of this project's committee
or a committer to upload to this release")
# Set the target directory to the release storage directory
- argv[path_index] = os.path.join(_CONFIG.STATE_DIR, "rsync-files",
path_project, path_version)
+ argv[path_index] = str(util.get_candidate_draft_dir() / path_project /
path_version)
_LOGGER.info(f"Modified command: {argv}")
# Create the release's storage directory if it doesn't exist
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index ee5b272..2c6e9c3 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -71,6 +71,10 @@ a {
font-weight: 450;
}
+strong a {
+ font-weight: 600;
+}
+
h1, h2, h3 {
font-weight: 475;
font-family: "Jost", system-ui, -apple-system, BlinkMacSystemFont, "Segoe
UI", "Oxygen", "Ubuntu", "Cantarell", "Open Sans", "Helvetica Neue", sans-serif;
diff --git a/atr/tasks/rsync.py b/atr/tasks/rsync.py
index 398f994..8ee50fa 100644
--- a/atr/tasks/rsync.py
+++ b/atr/tasks/rsync.py
@@ -16,7 +16,6 @@
# under the License.
import logging
-import os
from typing import Any, Final
import pydantic
@@ -54,7 +53,7 @@ async def analyse(args: dict[str, Any]) ->
tuple[models.TaskStatus, str | None,
async def _analyse_core(asf_uid: str, project_name: str, release_version: str)
-> dict[str, Any]:
"""Analyse an rsync upload."""
- base_path = os.path.join(_CONFIG.STATE_DIR, "rsync-files", project_name,
release_version)
+ base_path = util.get_candidate_draft_dir() / project_name / release_version
paths = await util.paths_recursive(base_path)
# for path in paths:
# # Add new tasks for each path
diff --git a/atr/templates/candidate-review.html
b/atr/templates/candidate-review.html
index 7cf1e02..5d378a0 100644
--- a/atr/templates/candidate-review.html
+++ b/atr/templates/candidate-review.html
@@ -41,8 +41,6 @@
<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">
- <a class="btn btn-primary"
- 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">
@@ -122,39 +120,6 @@
{% endif %}
</td>
</tr>
- <tr>
- <th>Downloads</th>
- <td>
- <div class="d-flex flex-wrap gap-2 align-items-center">
- <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_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') }}"
- class="btn btn-secondary">Verification Instructions</a>
- </div>
- </td>
- </tr>
- <tr>
- <th>Actions</th>
- <td>
- <form method="post"
- action="{{ url_for('root_package_delete') }}"
- class="d-inline">
- <input type="hidden"
- name="artifact_sha3"
- value="{{ package.artifact_sha3 }}" />
- <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?')">
- Delete package
- </button>
- </form>
- </td>
- </tr>
<tr>
<th>Artifact hash (SHA3-256)</th>
<td>{{ package.artifact_sha3 }}</td>
diff --git a/atr/templates/files-add-project.html
b/atr/templates/files-add-project.html
new file mode 100644
index 0000000..450e8aa
--- /dev/null
+++ b/atr/templates/files-add-project.html
@@ -0,0 +1,54 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Add file to {{ project_name }} {{ version_name }} ~ ATR
+{% endblock title %}
+
+{% block description %}
+ Add a single file to a release candidate.
+{% endblock description %}
+
+{% block content %}
+ <a href="{{ url_for('root_files_add') }}" class="back-link">← Back to Add
files</a>
+
+ <h1>Add file to {{ project_name }} {{ version_name }}</h1>
+ <p class="intro">Use this form to add a single file to this candidate
draft.</p>
+
+ {% if form.errors %}
+ <h2 class="text-danger">Form errors</h2>
+ <div class="error-message mt-3 mb-3">
+ {% for field, errors in form.errors.items() %}
+ {% for error in errors %}<p class="text-danger mb-1">{{ field }}: {{
error }}</p>{% endfor %}
+ {% endfor %}
+ </div>
+ {% endif %}
+
+ <form method="post"
+ enctype="multipart/form-data"
+ class="striking py-4 px-5">
+ {{ form.csrf_token }}
+ <div class="mb-3 pb-3 row border-bottom">
+ <label for="{{ form.file_path.id }}"
+ class="col-sm-3 col-form-label text-sm-end">{{
form.file_path.label.text }}:</label>
+ <div class="col-sm-8">
+ {{ form.file_path(class_="form-control") }}
+ {% if form.file_path.errors -%}<span class="error-message">{{
form.file_path.errors[0] }}</span>{%- endif %}
+ <span id="file_path-help" class="form-text text-muted">Enter the
path where the file should be saved in the release candidate</span>
+ </div>
+ </div>
+
+ <div class="mb-3 pb-3 row border-bottom">
+ <label for="{{ form.file_data.id }}"
+ class="col-sm-3 col-form-label text-sm-end">{{
form.file_data.label.text }}:</label>
+ <div class="col-sm-8">
+ {{ form.file_data(class_="form-control") }}
+ {% if form.file_data.errors -%}<span class="error-message">{{
form.file_data.errors[0] }}</span>{%- endif %}
+ <span id="file_data-help" class="form-text text-muted">Select the
file to upload</span>
+ </div>
+ </div>
+
+ <div class="row">
+ <div class="col-sm-9 offset-sm-3">{{ form.submit(class_="btn
btn-primary mt-3") }}</div>
+ </div>
+ </form>
+ {% endblock content %}
diff --git a/atr/templates/files-add.html b/atr/templates/files-add.html
index 44315ee..8294798 100644
--- a/atr/templates/files-add.html
+++ b/atr/templates/files-add.html
@@ -1,33 +1,27 @@
{% extends "layouts/base.html" %}
{% block title %}
- Add files ~ ATR
+ Candidate drafts ~ ATR
{% endblock title %}
{% block description %}
- Add files to editable ongoing releases using rsync.
+ Create and modify candidate drafts using rsync.
{% endblock description %}
{% block content %}
- <h1>Add files</h1>
+ <h1>Candidate drafts</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>
+ A <strong>candidate draft</strong> is an editable set of files which can
be <strong>frozen into a candidate release</strong> for voting on by the
project's committee.
</p>
+ <ul>
+ <li>Projects can work on multiple candidate drafts for different versions
simultaneously</li>
+ <li>A candidate draft is only editable until submitted for voting</li>
+ <li>You can only create a new candidate draft if you are a member of the
project's committee</li>
+ </ul>
- <h2>Editable ongoing releases</h2>
+ <h2>Modify existing draft</h2>
<div class="row row-cols-1 row-cols-md-2 g-4 mb-5">
- {% for release in editable_releases %}
+ {% for release in candidate_drafts %}
<div class="col" id="{{ release.name }}">
<div class="card h-100">
<div class="card-body position-relative">
@@ -40,11 +34,12 @@
{% endif %}
<p class="card-text">
{% if number_of_release_files(release) > 0 %}
- This editable ongoing release has <a href="{{
url_for('root_files_list', project_name=release.project.name,
version_name=release.version) }}">{{ number_of_release_files(release) }}
file(s)</a>.
+ This candidate draft has <a href="{{
url_for('root_files_list', project_name=release.project.name,
version_name=release.version) }}">{{ number_of_release_files(release) }}
file(s)</a>.
{% else %}
- This editable ongoing release doesn't have any files yet.
+ This candidate draft doesn't have any files yet.
{% endif %}
- Use the command below to add or modify files in this release:
+ <a href="{{ url_for('root_files_add_project',
project_name=release.project.name, version_name=release.version) }}">Upload a
file using the browser</a>,
+ or use the command below to add or modify files in this draft:
</p>
</div>
<pre class="card-footer bg-light border-1 pt-4 small">
@@ -54,17 +49,17 @@ rsync -av -e 'ssh -p 2222' your/files/ \
</div>
</div>
{% endfor %}
- {% if editable_releases|length == 0 %}
+ {% if candidate_drafts|length == 0 %}
<div class="col-12">
- <div class="alert alert-info">There are currently no editable ongoing
releases.</div>
+ <div class="alert alert-info">There are currently no candidate
drafts.</div>
</div>
{% endif %}
</div>
- <h2>Projects</h2>
+ <h2>Create draft from project</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 %}
+ {% set candidate_drafts = project.candidate_drafts %}
{# Show card for creating a new release if allowed #}
{% if asf_id in project.committee.committee_members %}
@@ -79,14 +74,14 @@ rsync -av -e 'ssh -p 2222' your/files/ \
<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 the ongoing releases
- {% for release in editable_releases %}
+ {% if candidate_drafts|length > 0 %}
+ This project already has candidate drafts
+ {% for release in candidate_drafts %}
<code><a href="#{{ release.name }}" class="border rounded
px-2">{{ release.version }}</a></code>,
{% endfor %}
but to create another one, use the command below.
{% else %}
- This project does not have an editable ongoing release.
+ This project does not have a candidate draft.
To create one and add files, use the command below.
{% endif %}
</p>
@@ -109,14 +104,14 @@ rsync -av -e 'ssh -p 2222' your/files/ \
<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 the ongoing releases
- {% for release in editable_releases %}
+ {% if candidate_drafts|length > 0 %}
+ This project already has candidate drafts
+ {% for release in candidate_drafts %}
<code><a href="#{{ release.name }}" class="border rounded
px-2">{{ release.version }}</a></code>,
{% endfor %}
but to create another one, use the command below.
{% else %}
- This project does not have an editable ongoing release.
+ This project does not have a candidate draft.
To create one, you must be a member of the project's
committee.
{% endif %}
</p>
diff --git a/atr/templates/files-list.html b/atr/templates/files-list.html
index 644740a..d018335 100644
--- a/atr/templates/files-list.html
+++ b/atr/templates/files-list.html
@@ -66,7 +66,7 @@
<tr>
<td>
{% if path in artifacts %}
- <strong>{{ path }}</strong>
+ <strong><a href="{{ url_for('root_download',
phase='candidate-draft', project=release.project.name, version=release.version,
path=path) }}">{{ path }}</a></strong>
{% elif path in metadata %}
<em>{{ path }}</em>
{% else %}
@@ -95,7 +95,9 @@
<h5 class="mb-0">Add or modify files</h5>
</div>
<div class="card-body">
- <p>Use the command below to add or modify files in this release using
rsync:</p>
+ <p>
+ <a href="{{ url_for('root_files_add_project',
project_name=release.project.name, version_name=release.version) }}">Upload a
file in the browser</a>, or use the command below to add or modify files in
this release using rsync:
+ </p>
</div>
<pre class="card-footer bg-light border-1 pt-4 small">
rsync -av -e 'ssh -p 2222' your/files/ \
diff --git a/atr/templates/includes/sidebar.html
b/atr/templates/includes/sidebar.html
index c64c9d3..637030a 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -47,24 +47,16 @@
</ul>
{% if current_user %}
- <h3>Candidate management</h3>
+ <h3>Candidate drafts</h3>
<ul>
- <li>
- <a href="{{ url_for('root_candidate_create') }}"
- {% if request.endpoint == 'root_candidate_create'
%}class="active"{% endif %}>Create candidate</a>
- </li>
- <!-- TODO: Don't show this if the user doesn't have any release
candidates? -->
- <li>
- <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>
+ {% if request.endpoint == 'root_files_add' %}class="active"{%
endif %}>Create and modify</a>
</li>
+ <!-- TODO: Don't show this if the user doesn't have any release
candidates? -->
<li>
<a href="{{ url_for('root_candidate_review') }}"
- {% if request.endpoint == 'root_candidate_review'
%}class="active"{% endif %}>Review candidates</a>
+ {% if request.endpoint == 'root_candidate_review'
%}class="active"{% endif %}>Review all</a>
</li>
</ul>
diff --git a/atr/user.py b/atr/user.py
index 9b0f7a7..8c8e7d2 100644
--- a/atr/user.py
+++ b/atr/user.py
@@ -21,14 +21,14 @@ import atr.db as db
import atr.db.models as models
-async def editable_releases(uid: str, user_projects: list[models.Project] |
None = None) -> list[models.Release]:
+async def candidate_drafts(uid: str, user_projects: list[models.Project] |
None = None) -> list[models.Release]:
if user_projects is None:
user_projects = await projects(uid)
- user_editable_releases: list[models.Release] = []
+ user_candidate_drafts: list[models.Release] = []
for p in user_projects:
- releases = await p.editable_releases
- user_editable_releases.extend(releases)
- return user_editable_releases
+ releases = await p.candidate_drafts
+ user_candidate_drafts.extend(releases)
+ return user_candidate_drafts
def is_committer(committee: models.Committee | None, uid: str) -> bool:
@@ -46,7 +46,7 @@ def is_committee_member(committee: models.Committee | None,
uid: str) -> bool:
async def projects(uid: str) -> list[models.Project]:
user_projects: list[models.Project] = []
async with db.session() as data:
- # Must have releases, because this is used in editable_releases
+ # Must have releases, because this is used in candidate_drafts
projects = await data.project(_committee=True, _releases=True).all()
for p in projects:
if p.committee is None:
diff --git a/atr/util.py b/atr/util.py
index 72d327e..24b3c68 100644
--- a/atr/util.py
+++ b/atr/util.py
@@ -18,7 +18,6 @@
import dataclasses
import functools
import hashlib
-import os
import pathlib
from collections.abc import Mapping
from typing import Annotated, Any, TypeVar
@@ -114,8 +113,24 @@ def get_admin_users() -> set[str]:
return set(config.get().ADMIN_USERS)
-def get_release_storage_dir() -> str:
- return str(config.get().RELEASE_STORAGE_DIR)
+def get_phase_dir() -> pathlib.Path:
+ return pathlib.Path(config.get().PHASE_STORAGE_DIR)
+
+
+def get_candidate_draft_dir() -> pathlib.Path:
+ return pathlib.Path(config.get().PHASE_STORAGE_DIR) / "candidate-draft"
+
+
+def get_candidate_release_dir() -> pathlib.Path:
+ return pathlib.Path(config.get().PHASE_STORAGE_DIR) / "candidate-release"
+
+
+def get_distributable_draft_dir() -> pathlib.Path:
+ return pathlib.Path(config.get().PHASE_STORAGE_DIR) / "distributable-draft"
+
+
+def get_distributable_release_dir() -> pathlib.Path:
+ return pathlib.Path(config.get().PHASE_STORAGE_DIR) /
"distributable-release"
def is_admin(user_id: str | None) -> bool:
@@ -125,16 +140,16 @@ def is_admin(user_id: str | None) -> bool:
return user_id in get_admin_users()
-async def paths_recursive(base_path: str, sort: bool = True) -> list[str]:
+async def paths_recursive(base_path: pathlib.Path, sort: bool = True) ->
list[pathlib.Path]:
"""List all paths recursively in alphabetical order from a given base
path."""
- paths: list[str] = []
+ paths: list[pathlib.Path] = []
- async def _recursive_list(current_path: str, relative_path: str = "") ->
None:
+ async def _recursive_list(current_path: pathlib.Path, relative_path:
pathlib.Path = pathlib.Path()) -> None:
try:
entries = await aiofiles.os.listdir(current_path)
for entry in entries:
- entry_path = os.path.join(current_path, entry)
- entry_rel_path = os.path.join(relative_path, entry) if
relative_path else entry
+ entry_path = current_path / entry
+ entry_rel_path = relative_path / entry
try:
stat_info = await aiofiles.os.stat(entry_path)
diff --git a/poetry.lock b/poetry.lock
index 40bff9f..7e866eb 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -127,6 +127,18 @@ yarl = ">=1.17.0,<2.0"
[package.extras]
speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns
(>=3.2.0) ; sys_platform == \"linux\" or sys_platform == \"darwin\"",
"brotlicffi ; platform_python_implementation != \"CPython\""]
+[[package]]
+name = "aioshutil"
+version = "1.5"
+description = "Asynchronous shutil module."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "aioshutil-1.5-py3-none-any.whl", hash =
"sha256:bc2a6cdcf1a8615b62f856154fd81131031d03f2834912ebb06d8a2391253652"},
+ {file = "aioshutil-1.5.tar.gz", hash =
"sha256:2756d6cd3bb03405dc7348ac11a0b60eb949ebd63cdd15f56e922410231c1201"},
+]
+
[[package]]
name = "aiosignal"
version = "1.3.2"
@@ -2758,4 +2770,4 @@ propcache = ">=0.2.0"
[metadata]
lock-version = "2.1"
python-versions = "~=3.13"
-content-hash =
"29329e4e21ece427687344476a493a09f3e128846a79f42ca850ebae5f4d6e7c"
+content-hash =
"71444cf5a7e27e1f15c9a3e88450f6f57a5e9e3001fd29ff0b44e1c85133747b"
diff --git a/pyproject.toml b/pyproject.toml
index d17d461..940f318 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,6 +29,7 @@ dependencies = [
"quart-wtforms~=1.0.3",
"email-validator~=2.2.0",
"sqlmodel~=0.0.24",
+ "aioshutil (>=1.5,<2.0)",
]
[dependency-groups]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]