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 c05f084 Add a page to promote a release candidate draft to a release
candidate
c05f084 is described below
commit c05f084b6702fc6d95249305dc68e8e3346f7488
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Mar 27 15:41:26 2025 +0200
Add a page to promote a release candidate draft to a release candidate
---
atr/routes/candidate_draft.py | 177 +++++++++++++++++++++++--
atr/ssh.py | 4 +
atr/templates/candidate-draft-add-project.html | 2 +-
atr/templates/candidate-draft-list.html | 2 -
atr/templates/candidate-draft-promote.html | 122 +++++++++++++++++
atr/templates/candidate-draft-tools.html | 2 +-
atr/templates/includes/sidebar.html | 2 +-
7 files changed, 294 insertions(+), 17 deletions(-)
diff --git a/atr/routes/candidate_draft.py b/atr/routes/candidate_draft.py
index 091d86c..9c13dd7 100644
--- a/atr/routes/candidate_draft.py
+++ b/atr/routes/candidate_draft.py
@@ -28,6 +28,7 @@ import re
from typing import Final
import aiofiles.os
+import aioshutil
import asfquart.base as base
import quart
import werkzeug.datastructures as datastructures
@@ -45,14 +46,6 @@ import atr.util as util
_LOGGER: Final = logging.getLogger(__name__)
-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")
-
-
async def _number_of_release_files(release: models.Release) -> int:
"""Return the number of files in the release."""
path_project = release.project.name
@@ -204,7 +197,15 @@ async def add_project(
session: routes.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()
+
+ class AddProjectForm(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")
+
+ form = await AddProjectForm.create_form()
if await form.validate_on_submit():
try:
file_path = None
@@ -231,6 +232,67 @@ async def add_project(
)
+class DeleteForm(util.QuartFormTyped):
+ """Form for deleting a candidate draft."""
+
+ candidate_draft_name = wtforms.StringField(
+ "Candidate draft name",
validators=[wtforms.validators.InputRequired("Candidate draft name is
required")]
+ )
+ confirm_delete = wtforms.StringField(
+ "Confirmation",
+ validators=[
+ wtforms.validators.InputRequired("Confirmation is required"),
+ wtforms.validators.Regexp("^DELETE$", message="Please type DELETE
to confirm"),
+ ],
+ )
+ submit = wtforms.SubmitField("Delete candidate draft")
+
+
[email protected]("/candidate-draft/delete", methods=["POST"])
+async def delete(session: routes.CommitterSession) -> response.Response:
+ """Delete a candidate draft and all its associated files."""
+ form = await DeleteForm.create_form(data=await quart.request.form)
+
+ if not await form.validate_on_submit():
+ for _field, errors in form.errors.items():
+ for error in errors:
+ await quart.flash(f"{error}", "error")
+ return quart.redirect(util.as_url(promote))
+
+ candidate_draft_name = form.candidate_draft_name.data
+ if not candidate_draft_name:
+ raise routes.FlashError("Missing required parameters")
+
+ # Extract project name and version
+ try:
+ project_name, version = candidate_draft_name.rsplit("-", 1)
+ except ValueError:
+ raise routes.FlashError("Invalid candidate draft name format")
+
+ # Check that the user has access to the project
+ if not any((p.name == project_name) for p in (await
session.user_projects)):
+ raise routes.FlashError("You do not have access to this project")
+
+ # Delete the metadata from the database
+ async with db.session() as data:
+ async with data.begin():
+ try:
+ await _delete_candidate_draft(data, candidate_draft_name)
+ except Exception as e:
+ logging.exception("Error deleting candidate draft:")
+ raise routes.FlashError(f"Error deleting candidate draft:
{e!s}")
+
+ # Delete the files on disk
+ draft_dir = util.get_release_candidate_draft_dir() / project_name / version
+ if await aiofiles.os.path.exists(draft_dir):
+ # Believe this to be another bug in mypy Protocol handling
+ # TODO: Confirm that this is a bug, and report upstream
+ await aioshutil.rmtree(draft_dir) # type: ignore[call-arg]
+
+ await quart.flash("Candidate draft deleted successfully", "success")
+ return quart.redirect(util.as_url(promote))
+
+
@routes.committer("/candidate-draft/files/<project_name>/<version_name>")
async def files(session: routes.CommitterSession, project_name: str,
version_name: str) -> str:
"""Show all the files in the rsync upload directory for a release."""
@@ -362,8 +424,8 @@ async def checks(session: routes.CommitterSession,
project_name: str, version_na
)
[email protected]("/candidate-draft/delete/<project_name>/<version_name>/<path:file_path>",
methods=["POST"])
-async def delete(
[email protected]("/candidate-draft/delete-file/<project_name>/<version_name>/<path:file_path>",
methods=["POST"])
+async def delete_file(
session: routes.CommitterSession, project_name: str, version_name: str,
file_path: str
) -> response.Response:
"""Delete a specific file from the release candidate."""
@@ -448,7 +510,7 @@ async def hashgen(
@routes.committer("/candidate-draft/modify")
async def modify(session: routes.CommitterSession) -> str:
- """Show a page to allow the user to modify a candidate draft."""
+ """Allow the user to modify a candidate draft."""
# Do them outside of the template rendering call to ensure order
# The user_candidate_drafts call can use cached results from user_projects
user_projects = await session.user_projects
@@ -464,6 +526,80 @@ async def modify(session: routes.CommitterSession) -> str:
)
[email protected]("/candidate-draft/promote", methods=["GET", "POST"])
+async def promote(session: routes.CommitterSession) -> str | response.Response:
+ """Allow the user to promote a candidate draft."""
+
+ class PromoteForm(util.QuartFormTyped):
+ """Form for promoting a candidate draft."""
+
+ candidate_draft_name = wtforms.StringField(
+ "Candidate draft name",
validators=[wtforms.validators.InputRequired("Candidate draft name is
required")]
+ )
+ confirm_promote = wtforms.BooleanField(
+ "Confirmation", validators=[wtforms.validators.DataRequired("You
must confirm to proceed with promotion")]
+ )
+ submit = wtforms.SubmitField("Promote to candidate")
+
+ user_candidate_drafts = await session.user_candidate_drafts
+
+ # Create the forms
+ promote_form = await PromoteForm.create_form(
+ data=await quart.request.form if (quart.request.method == "POST") else
None
+ )
+ delete_form = await DeleteForm.create_form()
+
+ if (quart.request.method == "POST") and (await
promote_form.validate_on_submit()):
+ candidate_draft_name = promote_form.candidate_draft_name.data
+ if not candidate_draft_name:
+ raise routes.FlashError("Missing required parameters")
+
+ # Extract project name and version
+ try:
+ project_name, version_name = candidate_draft_name.rsplit("-", 1)
+ except ValueError:
+ raise routes.FlashError("Invalid candidate draft name format")
+
+ # Check that the user has access to the project
+ if not any((p.name == project_name) for p in (await
session.user_projects)):
+ raise routes.FlashError("You do not have access to this project")
+
+ async with db.session() as data:
+ try:
+ # Get the release
+ release = await data.release(name=candidate_draft_name,
_project=True).demand(
+ routes.FlashError("Candidate draft not found")
+ )
+
+ # Verify that it's in the correct phase
+ if release.phase !=
models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ raise routes.FlashError("This release is not in the
candidate draft phase")
+
+ # Promote it to a candidate
+ # TODO: Obtain a lock for this
+ source = str(util.get_release_candidate_draft_dir() /
project_name / version_name)
+ target = str(util.get_release_candidate_dir() / project_name /
version_name)
+ if await aiofiles.os.path.exists(target):
+ raise routes.FlashError("Candidate already exists")
+ release.phase =
models.ReleasePhase.RELEASE_CANDIDATE_BEFORE_VOTE
+ await data.commit()
+ await aioshutil.move(source, target)
+
+ await quart.flash("Candidate draft successfully promoted to
candidate", "success")
+ return quart.redirect(util.as_url(promote))
+
+ except Exception as e:
+ logging.exception("Error promoting candidate draft:")
+ raise routes.FlashError(f"Error promoting candidate draft:
{e!s}")
+
+ return await quart.render_template(
+ "candidate-draft-promote.html",
+ candidate_drafts=user_candidate_drafts,
+ promote_form=promote_form,
+ delete_form=delete_form,
+ )
+
+
@routes.committer("/candidate-draft/tools/<project_name>/<version_name>/<path:file_path>")
async def tools(session: routes.CommitterSession, project_name: str,
version_name: str, file_path: str) -> str:
"""Show the tools for a specific file."""
@@ -502,3 +638,20 @@ async def tools(session: routes.CommitterSession,
project_name: str, version_nam
release=release,
format_file_size=routes.format_file_size,
)
+
+
+async def _delete_candidate_draft(data: db.Session, candidate_draft_name: str)
-> None:
+ """Delete a candidate draft and all its associated files."""
+ # Check that the release exists
+ release = await data.release(name=candidate_draft_name, _project=True,
_packages=True).get()
+ if not release:
+ raise routes.FlashError("Candidate draft not found")
+ if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ raise routes.FlashError("Candidate draft is not in the release
candidate draft phase")
+
+ # Delete all associated packages first
+ for package in release.packages:
+ await data.delete(package)
+
+ # Delete the release record
+ await data.delete(release)
diff --git a/atr/ssh.py b/atr/ssh.py
index b413e07..1de7bf6 100644
--- a/atr/ssh.py
+++ b/atr/ssh.py
@@ -305,6 +305,10 @@ async def _handle_client(process:
asyncssh.SSHServerProcess) -> None:
)
data.add(release)
await data.commit()
+ if release.phase != models.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ raise RuntimeError("Release is not in the candidate draft
phase")
+ if release.stage != models.ReleaseStage.RELEASE_CANDIDATE:
+ raise RuntimeError("Release is not in the candidate stage")
# Add a task to analyse the new files
data.add(
diff --git a/atr/templates/candidate-draft-add-project.html
b/atr/templates/candidate-draft-add-project.html
index 9ddb951..6842797 100644
--- a/atr/templates/candidate-draft-add-project.html
+++ b/atr/templates/candidate-draft-add-project.html
@@ -9,7 +9,7 @@
{% endblock description %}
{% block content %}
- <a href="{{ as_url(routes.candidate_draft.add) }}" class="back-link">← Back
to Create and modify</a>
+ <a href="{{ as_url(routes.candidate_draft.add) }}" class="back-link">← Back
to add draft</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>
diff --git a/atr/templates/candidate-draft-list.html
b/atr/templates/candidate-draft-list.html
index 4a89646..9ac7b3a 100644
--- a/atr/templates/candidate-draft-list.html
+++ b/atr/templates/candidate-draft-list.html
@@ -49,8 +49,6 @@
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Files</h5>
- <a href="{{ as_url(routes.candidate_draft.add) }}"
- class="btn btn-sm btn-outline-secondary">Back to Create and modify</a>
</div>
<div class="card-body">
{% if paths|length > 0 %}
diff --git a/atr/templates/candidate-draft-promote.html
b/atr/templates/candidate-draft-promote.html
new file mode 100644
index 0000000..4f7ec09
--- /dev/null
+++ b/atr/templates/candidate-draft-promote.html
@@ -0,0 +1,122 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Promote candidate draft ~ ATR
+{% endblock title %}
+
+{% block description %}
+ Promote a release candidate draft to a release candidate.
+{% endblock description %}
+
+{% block stylesheets %}
+ {{ super() }}
+ <style>
+ .candidate-meta-item::after {
+ content: "•";
+ margin-left: 1rem;
+ color: #ccc;
+ }
+
+ .candidate-meta-item:last-child::after {
+ content: none;
+ }
+ </style>
+{% endblock stylesheets %}
+
+{% block content %}
+ <h1>Promote candidate draft</h1>
+
+ <p>Here are all the release candidate drafts to which you have access.</p>
+
+ {% if candidate_drafts %}
+ {% for candidate_draft in candidate_drafts %}
+ <div class="card mb-4 shadow-sm">
+ <div class="card-header bg-light">
+ <h3 class="card-title mb-0">{{ candidate_draft.project.name }}</h3>
+ </div>
+ <div class="card-body">
+ <div class="d-flex flex-wrap gap-3 pb-3 mb-3 border-bottom
candidate-meta text-secondary fs-6">
+ <span class="candidate-meta-item">Version: {{
candidate_draft.version }}</span>
+ <span class="candidate-meta-item">Stage: {{
candidate_draft.stage.value.upper() }}</span>
+ <span class="candidate-meta-item">Phase: {{
candidate_draft.phase.value.upper() }}</span>
+ <!-- <span class="candidate-meta-item">Project: {{
candidate_draft.project.name if candidate_draft.project else "unknown"
}}</span> -->
+ <span class="candidate-meta-item">Created: {{
candidate_draft.created.strftime("%Y-%m-%d %H:%M UTC") }}</span>
+ </div>
+
+ <div class="row mt-3">
+ <div class="col-12">
+ <div class="card mb-3 border-primary">
+ <div class="card-header bg-primary bg-opacity-10 text-primary">
+ <h5 class="mb-0">Promote candidate draft to candidate</h5>
+ </div>
+ <div class="card-body">
+ <p class="text-muted mb-3">Promoting will freeze this
candidate draft into a candidate that can be voted on.</p>
+ <form method="post" action="{{
as_url(routes.candidate_draft.promote) }}">
+ {{ promote_form.hidden_tag() }}
+ <input type="hidden"
+ name="candidate_draft_name"
+ value="{{ candidate_draft.name }}" />
+ <div class="mb-3">
+ <div class="form-check">
+ {{
promote_form.confirm_promote(class="form-check-input", id="confirm_promote_" +
candidate_draft.name) }}
+ <label class="form-check-label"
+ for="confirm_promote_{{ candidate_draft.name
}}">
+ I understand this will freeze the candidate draft
into a candidate
+ </label>
+ </div>
+ {% if promote_form.confirm_promote.errors %}
+ <div class="invalid-feedback d-block">
+ {% for error in promote_form.confirm_promote.errors
%}{{ error }}{% endfor %}
+ </div>
+ {% endif %}
+ </div>
+ <button type="submit" class="btn btn-primary">{{
promote_form.submit.label.text }}</button>
+ </form>
+ </div>
+ </div>
+ </div>
+
+ <div class="col-12">
+ <div class="card border-danger">
+ <div class="card-header bg-danger bg-opacity-10 text-danger">
+ <h5 class="mb-0">Delete candidate draft</h5>
+ </div>
+ <div class="card-body">
+ <p class="text-muted mb-3">Warning: This action will
permanently delete this candidate draft and cannot be undone.</p>
+ <form method="post" action="{{
as_url(routes.candidate_draft.delete) }}">
+ {{ delete_form.hidden_tag() }}
+ <input type="hidden"
+ name="candidate_draft_name"
+ value="{{ candidate_draft.name }}" />
+ <div class="mb-3">
+ <label for="confirm_delete_{{ candidate_draft.name }}"
class="form-label">
+ Type <strong>DELETE</strong> to confirm:
+ </label>
+ {{ delete_form.confirm_delete(class="form-control mt-2",
+ id="confirm_delete_" +
candidate_draft.name,
+ placeholder="DELETE") }}
+ {% if delete_form.confirm_delete.errors %}
+ <div class="invalid-feedback d-block">
+ {% for error in delete_form.confirm_delete.errors
%}{{ error }}{% endfor %}
+ </div>
+ {% endif %}
+ </div>
+ <button type="submit" class="btn btn-danger">{{
delete_form.submit.label.text }}</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ {% endfor %}
+ {% else %}
+ <div class="alert alert-info">
+ <p class="mb-0">You haven't created any release candidate drafts yet.</p>
+ </div>
+ {% endif %}
+{% endblock content %}
+
+{% block javascripts %}
+ {{ super() }}
+{% endblock javascripts %}
diff --git a/atr/templates/candidate-draft-tools.html
b/atr/templates/candidate-draft-tools.html
index 3f0aa62..449de9f 100644
--- a/atr/templates/candidate-draft-tools.html
+++ b/atr/templates/candidate-draft-tools.html
@@ -49,7 +49,7 @@
<h3>Delete file</h3>
<p>This tool deletes the file from the candidate draft.</p>
<form method="post"
- action="{{ as_url(routes.candidate_draft.delete,
project_name=project_name, version_name=version_name, file_path=file_path) }}">
+ action="{{ as_url(routes.candidate_draft.delete_file,
project_name=project_name, version_name=version_name, file_path=file_path) }}">
<button type="submit"
class="btn btn-danger"
onclick="return confirm('Are you sure you want to delete this
file?')">Delete file</button>
diff --git a/atr/templates/includes/sidebar.html
b/atr/templates/includes/sidebar.html
index b3f33b8..1db8344 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -38,7 +38,7 @@
</li>
<!-- TODO: Don't show this if the user doesn't have any release
candidates? -->
<li>
- <a href="{{ as_url(routes.candidate.review) }}">Review all</a>
+ <a href="{{ as_url(routes.candidate_draft.promote) }}">Promote
draft</a>
</li>
</ul>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]