This is an automated email from the ASF dual-hosted git repository.

tn 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 9f59d73  Add candidate draft directory
9f59d73 is described below

commit 9f59d73d731e48987220be727eed15f99020088b
Author: Thomas Neidhart <[email protected]>
AuthorDate: Fri Mar 28 15:21:42 2025 +0100

    Add candidate draft directory
---
 atr/routes/draft.py                 |  50 ++++++---
 atr/templates/draft-directory.html  | 214 ++++++++++++++++++++++++++++++++++++
 atr/templates/includes/sidebar.html |   3 +
 atr/templates/layouts/base.html     |   1 +
 4 files changed, 253 insertions(+), 15 deletions(-)

diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 56701fb..1d6b48a 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -172,6 +172,26 @@ def _path_warnings_errors_metadata(
     return warnings, errors
 
 
[email protected]("/drafts")
+async def directory(session: routes.CommitterSession) -> str:
+    user_projects = await session.user_projects
+    user_candidate_drafts = await session.user_candidate_drafts
+
+    promote_form = await PromoteForm.create_form()
+    delete_form = await DeleteForm.create_form()
+
+    return await quart.render_template(
+        "draft-directory.html",
+        asf_id=session.uid,
+        projects=user_projects,
+        server_domain=session.host,
+        number_of_release_files=_number_of_release_files,
+        candidate_drafts=user_candidate_drafts,
+        promote_form=promote_form,
+        delete_form=delete_form,
+    )
+
+
 @routes.committer("/draft/add", methods=["GET", "POST"])
 async def add(session: routes.CommitterSession) -> response.Response | str:
     """Show a page to allow the user to rsync files to candidate drafts."""
@@ -193,7 +213,7 @@ async def add(session: routes.CommitterSession) -> 
response.Response | str:
             # TODO: Show the form with errors
             return await session.redirect(add, error="Invalid form data")
         await _add(session, form)
-        return await session.redirect(add, success="Release candidate created 
successfully")
+        return await session.redirect(directory, success="Release candidate 
created successfully")
 
     return await quart.render_template(
         "draft-add.html",
@@ -289,7 +309,7 @@ async def delete(session: routes.CommitterSession) -> 
response.Response:
         # TODO: Confirm that this is a bug, and report upstream
         await aioshutil.rmtree(draft_dir)  # type: ignore[call-arg]
 
-    return await session.redirect(promote, success="Candidate draft deleted 
successfully")
+    return await session.redirect(directory, success="Candidate draft deleted 
successfully")
 
 
 
@routes.committer("/draft/delete-file/<project_name>/<version_name>/<path:file_path>",
 methods=["POST"])
@@ -396,21 +416,21 @@ async def modify(session: routes.CommitterSession) -> str:
     )
 
 
[email protected]("/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."""
 
-    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")
 
-        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")
 
[email protected]("/draft/promote", methods=["GET", "POST"])
+async def promote(session: routes.CommitterSession) -> str | response.Response:
+    """Allow the user to promote a candidate draft."""
     user_candidate_drafts = await session.user_candidate_drafts
 
     # Create the forms
@@ -455,7 +475,7 @@ async def promote(session: routes.CommitterSession) -> str 
| response.Response:
                 await data.commit()
                 await aioshutil.move(source, target)
 
-                return await session.redirect(promote, success="Candidate 
draft successfully promoted to candidate")
+                return await session.redirect(directory, success="Candidate 
draft successfully promoted to candidate")
 
             except Exception as e:
                 logging.exception("Error promoting candidate draft:")
diff --git a/atr/templates/draft-directory.html 
b/atr/templates/draft-directory.html
new file mode 100644
index 0000000..6ce6470
--- /dev/null
+++ b/atr/templates/draft-directory.html
@@ -0,0 +1,214 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Release candidate drafts ~ ATR
+{% endblock title %}
+
+{% block description %}
+  Active release candidate drafts.
+{% endblock description %}
+
+{% block content %}
+  <h1>Release candidate draft directory</h1>
+  <p class="intro">
+    A <strong>candidate draft</strong> is an editable set of files which can 
be <strong>frozen and promoted into a candidate release</strong> for voting on 
by the project's committee.
+  </p>
+  <ul>
+    <li>You can only create a new candidate draft if you are a member of the 
project's committee</li>
+    <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>
+  </ul>
+
+  <div class="row row-cols-1 row-cols-md-2 g-4 mb-5">
+    {% for release in candidate_drafts %}
+      {% set release_id = release.name.replace('.', '_') %}
+      <div class="col" id="{{ release.name }}">
+        <div class="card h-100">
+          <div class="card-header">
+            <h5 class="card-title">
+              {{ release.project.display_name }} {{ release.version }}&nbsp;
+              <span class="badge bg-success align-top">Draft</span>
+            </h5>
+            {% if release.project.committee %}
+              <h6 class="card-subtitle mb-2 text-muted">{{ 
release.project.committee.display_name }}</h6>
+            {% endif %}
+            <div class="position-absolute top-0 end-0 m-2">
+              <button class="btn btn-primary"
+                      data-bs-toggle="modal"
+                      data-bs-target="#promote-{{ release_id }}">
+                <i class="fa-solid fa-upload"></i>
+              </button>
+              <button class="btn btn-danger"
+                      data-bs-toggle="modal"
+                      data-bs-target="#delete-{{ release_id }}">
+                <i class="fa-solid fa-trash"></i>
+              </button>
+            </div>
+
+            <div class="modal modal-lg fade"
+                 id="promote-{{ release_id }}"
+                 data-bs-backdrop="static"
+                 data-bs-keyboard="false"
+                 tabindex="-1"
+                 aria-labelledby="promote-{{ release_id }}-label"
+                 aria-hidden="true">
+              <div class="modal-dialog border-primary">
+                <div class="modal-content">
+                  <div class="modal-header bg-primary bg-opacity-10 
text-primary">
+                    <h1 class="modal-title fs-5" id="promote-{{ release_id 
}}-label">Promote candidate draft to candidate</h1>
+                    <button type="button"
+                            class="btn-close"
+                            data-bs-dismiss="modal"
+                            aria-label="Close"></button>
+                  </div>
+                  <div class="modal-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.draft.promote) }}">
+                      {{ promote_form.hidden_tag() }}
+                      <input type="hidden" name="candidate_draft_name" 
value="{{ release.name }}" />
+                      <div class="mb-3">
+                        <div class="form-check">
+                          <input class="form-check-input"
+                                 id="confirm_promote_{{ release_id }}"
+                                 name="confirm_promote"
+                                 required=""
+                                 type="checkbox"
+                                 value="y"
+                                 onchange="updateButton(this, 
'promote-button-{{ release_id }}')" />
+                          <label class="form-check-label" 
for="confirm_promote_{{ release_id }}">
+                            I understand this will freeze the candidate draft 
into a candidate
+                          </label>
+                        </div>
+                      </div>
+                      <button type="submit"
+                              id="promote-button-{{ release_id }}"
+                              disabled
+                              class="btn btn-primary">Promote to 
candidate</button>
+                    </form>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div class="modal modal-lg fade"
+                 id="delete-{{ release_id }}"
+                 data-bs-backdrop="static"
+                 data-bs-keyboard="false"
+                 tabindex="-1"
+                 aria-labelledby="promote-{{ release_id }}-label"
+                 aria-hidden="true">
+              <div class="modal-dialog border-primary">
+                <div class="modal-content">
+                  <div class="modal-header bg-danger bg-opacity-10 
text-danger">
+                    <h1 class="modal-title fs-5" id="delete-{{ release_id 
}}-label">Delete candidate draft</h1>
+                    <button type="button"
+                            class="btn-close"
+                            data-bs-dismiss="modal"
+                            aria-label="Close"></button>
+                  </div>
+                  <div class="modal-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.draft.delete) 
}}">
+                      {{ delete_form.hidden_tag() }}
+                      <input type="hidden" name="candidate_draft_name" 
value="{{ release.name }}" />
+                      <div class="mb-3">
+                        <label for="confirm_delete_{{ release_id }}" 
class="form-label">
+                          Type <strong>DELETE</strong> to confirm:
+                        </label>
+                        <input class="form-control mt-2"
+                               id="confirm_delete_{{ release_id }}"
+                               name="confirm_delete"
+                               placeholder="DELETE"
+                               required=""
+                               type="text"
+                               value=""
+                               onkeyup="updateDeleteButton(this, 
'delete-button-{{ release_id }}')" />
+                      </div>
+                      <button type="submit"
+                              id="delete-button-{{ release_id }}"
+                              disabled
+                              class="btn btn-danger">Delete candidate 
draft</button>
+                    </form>
+                  </div>
+                </div>
+              </div>
+            </div>
+
+          </div>
+          <div class="card-body position-relative">
+            <p class="card-text">
+              {% if number_of_release_files(release) > 0 %}
+                This candidate draft has <a href="{{ 
as_url(routes.draft.files, project_name=release.project.name, 
version_name=release.version) }}">{{ number_of_release_files(release) }} 
file(s)</a>.
+              {% else %}
+                This candidate draft doesn't have any files yet.
+              {% endif %}
+              <a href="{{ as_url(routes.draft.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>
+          <div class="card-footer bg-light border-1 pt-4 pb-4 
position-relative">
+            <button class="btn btn-sm btn-outline-secondary atr-copy-btn fs-6 
position-absolute top-0 end-0 m-2"
+                    data-clipboard-target="#cmd-{{ release.name|replace('.', 
'-') }}">
+              <i class="bi bi-clipboard"></i> Copy
+            </button>
+            <pre class="small mb-0" id="cmd-{{ release.name|replace('.', '-') 
}}">rsync -av -e 'ssh -p 2222' your/files/ \
+    {{ asf_id }}@{{ server_domain }}:/{{ release.project.name }}/{{ 
release.version }}/</pre>
+          </div>
+        </div>
+      </div>
+    {% endfor %}
+    {% if candidate_drafts|length == 0 %}
+      <div class="col-12">
+        <div class="alert alert-info">There are currently no candidate 
drafts.</div>
+      </div>
+    {% endif %}
+  </div>
+{% endblock content %}
+
+{% block javascripts %}
+  {{ super() }}
+  <script>
+      document.addEventListener("DOMContentLoaded", function() {
+          const copyButtons = document.querySelectorAll(".atr-copy-btn");
+
+          copyButtons.forEach(button => {
+              button.addEventListener("click", function() {
+                  const targetId = this.getAttribute("data-clipboard-target");
+                  const targetElement = document.querySelector(targetId);
+
+                  if (targetElement) {
+                      const textToCopy = targetElement.textContent;
+
+                      navigator.clipboard.writeText(textToCopy)
+                          .then(() => {
+                              const originalText = this.innerHTML;
+                              this.innerHTML = '<i class="bi bi-check2"></i> 
Copied!';
+
+                              // Reset the button text after 2000ms
+                              setTimeout(() => {
+                                  this.innerHTML = originalText;
+                              }, 2000);
+                          })
+                          .catch(err => {
+                              console.error("Failed to copy: ", err);
+                              this.innerHTML = '<i class="bi 
bi-exclamation-triangle"></i> Failed!';
+
+                              setTimeout(() => {
+                                  this.innerHTML = '<i class="bi 
bi-clipboard"></i> Copy';
+                              }, 2000);
+                          });
+                  }
+              });
+          });
+      });
+
+      function updateButton(checkboxElement, buttonId) {
+          let button = document.getElementById(buttonId);
+          button.disabled = !checkboxElement.checked;
+      }
+
+      function updateDeleteButton(inputElement, buttonId) {
+          let button = document.getElementById(buttonId);
+          button.disabled = inputElement.value !== "DELETE";
+      }
+  </script>
+{% endblock javascripts %}
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index 925ba38..4f55ddb 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -30,6 +30,9 @@
     {% if current_user %}
       <h3>Release candidate drafts</h3>
       <ul>
+        <li>
+          <a href="{{ as_url(routes.draft.directory) }}">Review drafts</a>
+        </li>
         <li>
           <a href="{{ as_url(routes.draft.add) }}">Add draft</a>
         </li>
diff --git a/atr/templates/layouts/base.html b/atr/templates/layouts/base.html
index 20bbe9d..b77c432 100644
--- a/atr/templates/layouts/base.html
+++ b/atr/templates/layouts/base.html
@@ -53,6 +53,7 @@
     </div>
 
     {% block javascripts %}
+      <script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') 
}}"></script>
     {% endblock javascripts %}
   </body>
 </html>


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to