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]

Reply via email to