You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/03/27 17:24:48 UTC

(tooling-trusted-release) branch main updated: Move and update the form to create blank candidate drafts

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 8b55de1  Move and update the form to create blank candidate drafts
8b55de1 is described below

commit 8b55de1b86d0c9dc597a93a972cd7b080208bd3d
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Mar 27 19:23:59 2025 +0200

    Move and update the form to create blank candidate drafts
---
 atr/routes/candidate.py             | 116 ---------------------------------
 atr/routes/draft.py                 | 125 +++++++++++++++++++++++++++---------
 atr/templates/candidate-create.html |  73 ---------------------
 atr/templates/candidate-vote.html   |   2 +-
 atr/templates/draft-add.html        |  97 +++++++++++++++++++---------
 atr/templates/index.html            |   2 +-
 6 files changed, 160 insertions(+), 255 deletions(-)

diff --git a/atr/routes/candidate.py b/atr/routes/candidate.py
index 8456f71..bdb6342 100644
--- a/atr/routes/candidate.py
+++ b/atr/routes/candidate.py
@@ -17,35 +17,20 @@
 
 """candidate.py"""
 
-import datetime
-
 import asfquart
 import asfquart.base as base
 import quart
 import werkzeug.wrappers.response as response
-import wtforms
 
 import atr.db as db
 import atr.db.models as models
 import atr.db.service as service
 import atr.routes as routes
-import atr.util as util
 
 if asfquart.APP is ...:
     raise RuntimeError("APP is not set")
 
 
-class ReleaseAddForm(util.QuartFormTyped):
-    committee_name = wtforms.StringField(
-        "Committee", validators=[wtforms.validators.InputRequired("Committee name is required")]
-    )
-    version = wtforms.StringField("Version", validators=[wtforms.validators.InputRequired("Version is required")])
-    project_name = wtforms.StringField(
-        "Project name", validators=[wtforms.validators.InputRequired("Project name is required")]
-    )
-    submit = wtforms.SubmitField("Create release candidate")
-
-
 def format_artifact_name(project_name: str, version: str, is_podling: bool = False) -> str:
     """Format an artifact name according to Apache naming conventions.
 
@@ -59,107 +44,6 @@ def format_artifact_name(project_name: str, version: str, is_podling: bool = Fal
     return f"apache-{project_name}-{version}"
 
 
-# Release functions
-
-
-async def release_add_post(session: routes.CommitterSession, request: quart.Request) -> str | response.Response:
-    """Handle POST request for creating a new release."""
-
-    def not_none(value: str | None) -> str:
-        if value is None:
-            raise ValueError("This field is required")
-        return value
-
-    form = await ReleaseAddForm.create_form(data=await request.form)
-
-    if not await form.validate():
-        # Get Committee objects for all committees and projects the user is a member of
-        async with db.session() as data:
-            committee_and_project_list = session.committees + session.projects
-            user_committees = await data.committee(name_in=committee_and_project_list).all()
-
-        # Return the form with validation errors
-        return await quart.render_template(
-            "candidate-create.html",
-            asf_id=session.uid,
-            user_committees=user_committees,
-            form=form,
-        )
-
-    committee_name = str(form.committee_name.data)
-    version = not_none(form.version.data)
-    project_name = not_none(form.project_name.data)
-
-    # TODO: Forbid creating a release with an existing project and version
-    # Create the release record in the database
-    async with db.session() as data:
-        async with data.begin():
-            committee = await data.committee(name=committee_name).get()
-            if not committee:
-                asfquart.APP.logger.error(f"Committee not found for project {committee_name}")
-                raise base.ASFQuartException("Committee not found", errorcode=404)
-
-            # Verify user is a PMC member or committer of the project
-            # We use committee.name, so this must come within the transaction
-            if committee.name not in (session.committees + session.projects):
-                raise base.ASFQuartException(
-                    f"You must be a PMC member or committer of {committee.display_name} to submit a release candidate",
-                    errorcode=403,
-                )
-
-            release_name = f"{project_name}-{version}"
-            project = await data.project(name=project_name).get()
-            if not project:
-                # Create a new project record
-                project = models.Project(
-                    name=project_name,
-                    committee_id=committee.id,
-                )
-                data.add(project)
-                # Must flush to get the project ID
-                await data.flush()
-
-            # Create release record with project
-            release = models.Release(
-                name=release_name,
-                stage=models.ReleaseStage.RELEASE_CANDIDATE,
-                phase=models.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
-                project_id=project.id,
-                project=project,
-                version=version,
-                created=datetime.datetime.now(datetime.UTC),
-            )
-            data.add(release)
-
-    # Redirect to the add package page with the storage token
-    return await session.redirect(vote, success="Release candidate created successfully")
-
-
-# Root functions
-
-
-@routes.committer("/candidate/create", methods=["GET", "POST"])
-async def create(session: routes.CommitterSession) -> response.Response | str:
-    """Create a new release in the database."""
-    # For POST requests, handle the release creation
-    if quart.request.method == "POST":
-        return await release_add_post(session, quart.request)
-
-    # Get PMC objects for all projects the user is a member of
-    async with db.session() as data:
-        project_list = session.committees + session.projects
-        user_committees = await data.committee(name_in=project_list).all()
-
-    # For GET requests, show the form
-    form = await ReleaseAddForm.create_form()
-    return await quart.render_template(
-        "candidate-create.html",
-        asf_id=session.uid,
-        user_committees=user_committees,
-        form=form,
-    )
-
-
 @routes.committer("/candidate/delete", methods=["POST"])
 async def delete(session: routes.CommitterSession) -> response.Response:
     """Delete a release candidate."""
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 9abfcfa..b4a314b 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -25,7 +25,7 @@ import hashlib
 import logging
 import pathlib
 import re
-from typing import Final
+from typing import Protocol, TypeVar
 
 import aiofiles.os
 import aioshutil
@@ -43,7 +43,17 @@ import atr.tasks as tasks
 import atr.util as util
 
 # _CONFIG: Final = config.get()
-_LOGGER: Final = logging.getLogger(__name__)
+# _LOGGER: Final = logging.getLogger(__name__)
+
+
+T = TypeVar("T")
+
+
+class AddProtocol(Protocol):
+    """Protocol for forms that create release candidate drafts."""
+
+    version_name: wtforms.StringField
+    project_name: wtforms.SelectField
 
 
 async def _number_of_release_files(release: models.Release) -> int:
@@ -146,14 +156,29 @@ def _path_warnings_errors_metadata(
     return warnings, errors
 
 
-@routes.committer("/draft/add")
-async def add(session: routes.CommitterSession) -> str:
+@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."""
     # 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
     user_candidate_drafts = await session.user_candidate_drafts
 
+    class AddForm(util.QuartFormTyped):
+        project_name = wtforms.SelectField("Project", choices=[(p.name, p.full_name or p.name) for p in user_projects])
+        version_name = wtforms.StringField(
+            "Version", validators=[wtforms.validators.InputRequired("Version is required")]
+        )
+        submit = wtforms.SubmitField("Create candidate draft")
+
+    form = await AddForm.create_form()
+    if quart.request.method == "POST":
+        if not await form.validate_on_submit():
+            # 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 quart.render_template(
         "draft-add.html",
         asf_id=session.uid,
@@ -161,37 +186,11 @@ async def add(session: routes.CommitterSession) -> str:
         server_domain=session.host,
         number_of_release_files=_number_of_release_files,
         candidate_drafts=user_candidate_drafts,
+        user_projects=user_projects,
+        form=form,
     )
 
 
-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_release_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)
-
-
 @routes.committer("/draft/add/<project_name>/<version_name>", methods=["GET", "POST"])
 async def add_project(
     session: routes.CommitterSession, project_name: str, version_name: str
@@ -656,3 +655,65 @@ async def _delete_candidate_draft(data: db.Session, candidate_draft_name: str) -
 
     # Delete the release record
     await data.delete(release)
+
+
+async def _add(session: routes.CommitterSession, form: AddProtocol) -> None:
+    """Handle POST request for creating a new release candidate draft."""
+    version = str(form.version_name.data)
+    project_name = str(form.project_name.data)
+
+    # Create the release record in the database
+    async with db.session() as data:
+        async with data.begin():
+            project = await data.project(name=project_name).get()
+            if not project:
+                raise routes.FlashError("Project not found")
+
+            # Verify user is a committee member or committer of the project
+            if project_name not in (p.name for p in await session.user_projects):
+                raise routes.FlashError(
+                    f"You must be a participant of {project_name} to submit a release candidate",
+                )
+
+            release_name = f"{project_name}-{version}"
+            # Check that the release does not already exist
+            if await data.release(name=release_name).get():
+                raise routes.FlashError("Release candidate already exists")
+
+            # Create release record with project
+            release = models.Release(
+                name=release_name,
+                stage=models.ReleaseStage.RELEASE_CANDIDATE,
+                phase=models.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+                project_id=project.id,
+                project=project,
+                version=version,
+                created=datetime.datetime.now(datetime.UTC),
+            )
+            data.add(release)
+
+
+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."""
+    # TODO: Rename to upload or file-upload
+    # Create target directory
+    target_dir = util.get_release_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 chunk := await asyncio.to_thread(file.stream.read, 8192):
+            await f.write(chunk)
diff --git a/atr/templates/candidate-create.html b/atr/templates/candidate-create.html
deleted file mode 100644
index bcfa835..0000000
--- a/atr/templates/candidate-create.html
+++ /dev/null
@@ -1,73 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
-  Create release candidate ~ ATR
-{% endblock title %}
-
-{% block description %}
-  Create a new release candidate.
-{% endblock description %}
-
-{% block content %}
-  <h1>Create release candidate</h1>
-  <p>
-    Welcome, <strong>{{ asf_id }}</strong>! Use this form to create a new release candidate for
-    your project. You can create a new release candidate for any project of which you are a release
-    manager. Release managers at the ASF must be either PMC members or committers.
-  </p>
-  <p class="mb-4">
-    ATR will guide you through the process of turning your release candidate into a final release,
-    providing tools and automation to help during each step of the workflow.
-  </p>
-
-  <form method="post"
-        enctype="multipart/form-data"
-        class="striking py-4 px-5">
-    <input type="hidden" name="form_type" value="single" />
-    {{ form.hidden_tag() }}
-    <div class="mb-3 pb-3 row border-bottom">
-      <label for="{{ form.committee_name.id }}"
-             class="col-sm-3 col-form-label text-sm-end">{{ form.committee_name.label.text }}:</label>
-      <div class="col-sm-8">
-        <select id="{{ form.committee_name.id }}"
-                name="{{ form.committee_name.name }}"
-                class="mb-2 form-select"
-                required>
-          <option value="">Select a committee...</option>
-          {% for committee in user_committees|sort(attribute='name') %}
-            <option value="{{ committee.name }}">{{ committee.display_name }}</option>
-          {% endfor %}
-        </select>
-        {% if form.committee_name.errors -%}<span class="error-message">{{ form.committee_name.errors[0] }}</span>{%- endif %}
-          {% if not user_committees %}
-            <p class="text-danger">You must be a (P)PMC member or committer to submit a release candidate.</p>
-          {% endif %}
-        </div>
-      </div>
-
-      <div class="mb-3 pb-3 row border-bottom">
-        <label for="{{ form.version.id }}"
-               class="col-sm-3 col-form-label text-sm-end">{{ form.version.label.text }}:</label>
-        <div class="col-sm-8">
-          {{ form.version(class_="form-control") }}
-          {% if form.version.errors -%}<span class="error-message">{{ form.version.errors[0] }}</span>{%- endif %}
-          </div>
-        </div>
-
-        <div class="mb-3 pb-3 row border-bottom">
-          <label for="{{ form.project_name.id }}"
-                 class="col-sm-3 col-form-label text-sm-end">{{ form.project_name.label.text }}:</label>
-          <div class="col-sm-8">
-            <!-- TODO: Add a dropdown for the project name, plus "add new project" -->
-            {{ form.project_name(class_="form-control") }}
-            {% if form.project_name.errors -%}<span class="error-message">{{ form.project_name.errors[0] }}</span>{%- endif %}
-              <!-- TODO: Add a subproject checkbox -->
-              <small class="text-muted">This can be the same as the committee name, but may be different if e.g. this is a subproject.</small>
-            </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/candidate-vote.html b/atr/templates/candidate-vote.html
index d6a80c9..7bc097d 100644
--- a/atr/templates/candidate-vote.html
+++ b/atr/templates/candidate-vote.html
@@ -63,7 +63,7 @@
 
   <h2>Other actions</h2>
   <p>
-    <a href="{{ as_url(routes.candidate.create) }}">Create a release candidate</a>
+    <a href="{{ as_url(routes.draft.add) }}">Add a release candidate draft</a>
   </p>
 {% endblock content %}
 
diff --git a/atr/templates/draft-add.html b/atr/templates/draft-add.html
index 10a94d3..900d3e5 100644
--- a/atr/templates/draft-add.html
+++ b/atr/templates/draft-add.html
@@ -87,43 +87,76 @@
       {% endif %}
     {% endfor %}
   </div>
-{% endblock content %}
 
-{% block javascripts %}
-  {{ super() }}
-  <script>
-      document.addEventListener("DOMContentLoaded", function() {
-          const copyButtons = document.querySelectorAll(".atr-copy-btn");
+  <p>Or use the following form to create an empty release candidate draft.</p>
 
-          copyButtons.forEach(button => {
-              button.addEventListener("click", function() {
-                  const targetId = this.getAttribute("data-clipboard-target");
-                  const targetElement = document.querySelector(targetId);
+  <form method="post"
+        enctype="multipart/form-data"
+        class="striking py-4 px-5">
+    <input type="hidden" name="form_type" value="single" />
+    {{ form.hidden_tag() }}
+    <div class="mb-3 pb-3 row border-bottom">
+      <label for="{{ form.project_name.id }}"
+             class="col-sm-3 col-form-label text-sm-end">{{ form.project_name.label.text }}:</label>
+      <div class="col-sm-8">
+        {{ form.project_name(class_="mb-2 form-select") }}
+        {% if form.project_name.errors -%}<span class="error-message">{{ form.project_name.errors[0] }}</span>{%- endif %}
+          {% if not user_projects %}
+            <p class="text-danger">You must be a participant of a project to submit a release candidate.</p>
+          {% endif %}
+        </div>
+      </div>
+
+      <div class="mb-3 pb-3 row border-bottom">
+        <label for="{{ form.version_name.id }}"
+               class="col-sm-3 col-form-label text-sm-end">{{ form.version_name.label.text }}:</label>
+        <div class="col-sm-8">
+          {{ form.version_name(class_="form-control") }}
+          {% if form.version_name.errors -%}<span class="error-message">{{ form.version_name.errors[0] }}</span>{%- endif %}
+          </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 %}
+
+    {% 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;
+                      if (targetElement) {
+                          const textToCopy = targetElement.textContent;
 
-                      navigator.clipboard.writeText(textToCopy)
-                          .then(() => {
-                              const originalText = this.innerHTML;
-                              this.innerHTML = '<i class="bi bi-check2"></i> Copied!';
+                          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!';
+                                  // 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);
-                          });
-                  }
+                                  setTimeout(() => {
+                                      this.innerHTML = '<i class="bi bi-clipboard"></i> Copy';
+                                  }, 2000);
+                              });
+                      }
+                  });
               });
           });
-      });
-  </script>
-{% endblock javascripts %}
+      </script>
+    {% endblock javascripts %}
diff --git a/atr/templates/index.html b/atr/templates/index.html
index 5706ed3..09db81d 100644
--- a/atr/templates/index.html
+++ b/atr/templates/index.html
@@ -23,7 +23,7 @@
 
   <h2>Getting Started</h2>
   <p>
-    To submit a release candidate, you must be a PMC member of the target project. First, <a href="{{ as_url(routes.keys.add) }}">add your signing key</a>, then <a href="{{ as_url(routes.candidate.create) }}">create a release candidate</a>.
+    To create a release candidate draft, you must be a committee member of the target project. First, <a href="{{ as_url(routes.keys.ssh_add) }}">add your SSH key</a>, then <a href="{{ as_url(routes.draft.add) }}">add a release candidate draft</a>.
   </p>
 
   <h2>Documentation</h2>


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org