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 }}
+ <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]