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 7df8ddc  Add a dedicated form for creating a new derived project
7df8ddc is described below

commit 7df8ddc810acbf6f213b38fce0f36809f8e9f4f6
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Apr 10 16:13:08 2025 +0100

    Add a dedicated form for creating a new derived project
---
 atr/routes/projects.py                    |  97 ++++++++++++++++++++++-
 atr/static/css/atr.css                    |   2 +-
 atr/templates/candidate-vote-project.html |   2 +-
 atr/templates/draft-add-files.html        |   2 +-
 atr/templates/draft-add.html              |   2 +-
 atr/templates/keys-add.html               |   2 +-
 atr/templates/keys-ssh-add.html           |   2 +-
 atr/templates/keys-upload.html            |   2 +-
 atr/templates/project-add.html            | 123 ++++++++++++++++++++++++++++++
 atr/templates/project-view.html           |   9 +++
 atr/templates/vote-policy-form.html       |   2 +-
 11 files changed, 236 insertions(+), 9 deletions(-)

diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index 71db8e0..3f40f46 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -18,6 +18,8 @@
 """project.py"""
 
 import http.client
+import re
+from typing import Protocol
 
 import asfquart.base as base
 import quart
@@ -31,9 +33,102 @@ import atr.user as user
 import atr.util as util
 
 
+class AddFormProtocol(Protocol):
+    project_name: wtforms.SelectField
+    derived_project_name: wtforms.StringField
+    submit: wtforms.SubmitField
+
+
 @routes.committer("/project/add", methods=["GET", "POST"])
 async def add(session: routes.CommitterSession) -> response.Response | str:
-    raise NotImplementedError("Not implemented")
+    def long_name(project: models.Project) -> str:
+        if project.full_name:
+            return project.full_name
+        return project.name
+
+    user_projects = await session.user_projects
+
+    class AddForm(util.QuartFormTyped):
+        project_name = wtforms.SelectField("Project", choices=[(p.name, 
long_name(p)) for p in user_projects])
+        derived_project_name = wtforms.StringField(
+            "Derived project",
+            validators=[
+                wtforms.validators.InputRequired("Please provide a derived 
project name."),
+                wtforms.validators.Length(min=1, max=100),
+            ],
+        )
+        submit = wtforms.SubmitField("Create derived project")
+
+    form = await AddForm.create_form()
+
+    if await form.validate_on_submit():
+        return await _add_project(form)
+
+    return await quart.render_template("project-add.html", form=form)
+
+
+async def _add_project(form: AddFormProtocol) -> response.Response:
+    base_project_name = str(form.project_name.data)
+    derived_project_name = str(form.derived_project_name.data).strip()
+
+    def _generate_label(text: str) -> str:
+        # TODO: We should probably add long name validation
+        text = text.lower()
+        text = re.sub(r" +", "-", text)
+        text = re.sub(r"[^a-z0-9-]", "", text)
+        return text
+
+    async with db.session() as data:
+        # Get the base project to derive from
+        base_project = await data.project(name=base_project_name).get()
+        if not base_project:
+            # This should not happen, assuming that the dropdown is populated 
correctly
+            raise routes.FlashError(f"Base project {base_project_name} not 
found")
+
+        # Construct the new label
+        derived_label = _generate_label(derived_project_name)
+        if not derived_label:
+            raise routes.FlashError("Derived project name must contain valid 
characters for label generation")
+        new_project_label = f"{base_project.name}-{derived_label}"
+
+        # Construct the new full name
+        # We ensure that parenthesised suffixes like "(Incubating)" are 
preserved
+        base_name = base_project.full_name or base_project.name
+        match = re.match(r"^(.*?) *(\(.*\))?$", base_name)
+        if match:
+            main_part = match.group(1).strip()
+            suffix_part = match.group(2)
+        else:
+            main_part = base_name.strip()
+            suffix_part = None
+
+        if suffix_part:
+            new_project_full_name = f"{main_part} {derived_project_name} 
{suffix_part}"
+        else:
+            new_project_full_name = f"{main_part} {derived_project_name}"
+        new_project_full_name = re.sub(r"  +", " ", 
new_project_full_name).strip()
+
+        # Check whether the derived project already exists by its constructed 
label
+        if await data.project(name=new_project_label).get():
+            raise routes.FlashError(f"Derived project {new_project_label} 
already exists")
+
+        project = models.Project(
+            name=new_project_label,
+            full_name=new_project_full_name,
+            is_podling=base_project.is_podling,
+            is_retired=base_project.is_retired,
+            description=base_project.description,
+            category=base_project.category,
+            programming_languages=base_project.programming_languages,
+            committee_id=base_project.committee_id,
+            vote_policy_id=base_project.vote_policy_id,
+            # TODO: Add "created" and "created_by" to models.Project perhaps?
+        )
+
+        data.add(project)
+        await data.commit()
+
+    return quart.redirect(util.as_url(view, name=new_project_label))
 
 
 @routes.public("/projects")
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index 1c195f3..30b6d4c 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -185,7 +185,7 @@ summary {
     cursor: pointer;
 }
 
-form.striking {
+form.atr-canary {
     background-color: #ffffee;
     border: 2px solid #ddddbb;
     padding: 1rem;
diff --git a/atr/templates/candidate-vote-project.html 
b/atr/templates/candidate-vote-project.html
index e51115e..46c2336 100644
--- a/atr/templates/candidate-vote-project.html
+++ b/atr/templates/candidate-vote-project.html
@@ -38,7 +38,7 @@
     {% endif %}
 
     <form method="post"
-          class="striking py-4 px-5"
+          class="atr-canary py-4 px-5"
           action="{{ as_url(routes.candidate.vote_project, 
project_name=release.project.name, version=release.version) }}">
       {{ form.hidden_tag() if form.hidden_tag }}
       {{ form.release_name }}
diff --git a/atr/templates/draft-add-files.html 
b/atr/templates/draft-add-files.html
index 7f54d86..9df7401 100644
--- a/atr/templates/draft-add-files.html
+++ b/atr/templates/draft-add-files.html
@@ -16,7 +16,7 @@
 
   <form method="post"
         enctype="multipart/form-data"
-        class="striking py-4 px-5 needs-validation"
+        class="atr-canary py-4 px-5 needs-validation"
         novalidate>
     {{ form.csrf_token }}
     <div class="mb-3 pb-3 row border-bottom">
diff --git a/atr/templates/draft-add.html b/atr/templates/draft-add.html
index b1923a0..cfa591d 100644
--- a/atr/templates/draft-add.html
+++ b/atr/templates/draft-add.html
@@ -23,7 +23,7 @@
 
   <form method="post"
         enctype="multipart/form-data"
-        class="striking py-4 px-5">
+        class="atr-canary py-4 px-5">
     <input type="hidden" name="form_type" value="single" />
     {{ form.hidden_tag() }}
     <div class="mb-3 pb-3 row border-bottom">
diff --git a/atr/templates/keys-add.html b/atr/templates/keys-add.html
index aa47e5c..398c11d 100644
--- a/atr/templates/keys-add.html
+++ b/atr/templates/keys-add.html
@@ -16,7 +16,7 @@
     {% if form.errors %}<div class="alert alert-danger">Please correct the 
errors below:</div>{% endif %}
 
     <form method="post"
-          class="striking py-4 px-5"
+          class="atr-canary py-4 px-5"
           action="{{ as_url(routes.keys.add) }}"
           novalidate>
       {{ form.hidden_tag() if form.hidden_tag }}
diff --git a/atr/templates/keys-ssh-add.html b/atr/templates/keys-ssh-add.html
index 26db6ed..e04ba03 100644
--- a/atr/templates/keys-ssh-add.html
+++ b/atr/templates/keys-ssh-add.html
@@ -27,7 +27,7 @@
     </div>
   {% endif %}
 
-  <form method="post" class="striking">
+  <form method="post" class="atr-canary">
     {{ form.csrf_token }}
     <div class="mb-4">
       <div class="mb-3">
diff --git a/atr/templates/keys-upload.html b/atr/templates/keys-upload.html
index 218e92b..812e722 100644
--- a/atr/templates/keys-upload.html
+++ b/atr/templates/keys-upload.html
@@ -72,7 +72,7 @@
     {% endfor %}
   {% endif %}
 
-  <form method="post" class="striking" enctype="multipart/form-data">
+  <form method="post" class="atr-canary" enctype="multipart/form-data">
     {{ form.csrf_token }}
 
     <div class="mb-4">
diff --git a/atr/templates/project-add.html b/atr/templates/project-add.html
new file mode 100644
index 0000000..0793c0b
--- /dev/null
+++ b/atr/templates/project-add.html
@@ -0,0 +1,123 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+  Add project ~ ATR
+{% endblock title %}
+
+{% block description %}
+  Add a new project based on an existing one.
+{% endblock description %}
+
+{% block content %}
+  <h1>Add derived project</h1>
+  <p class="intro">Derive a new project from an existing one, by adding a 
suffix.</p>
+
+  <form method="post" class="atr-canary py-4">
+    {{ 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_="form-select") }}
+        {% if form.project_name.errors -%}<span class="text-danger small">{{ 
form.project_name.errors[0] }}</span>{%- endif %}
+          <p class="text-muted small mt-1">Select the base ASF project.</p>
+        </div>
+      </div>
+
+      <div class="mb-3 pb-3 row border-bottom">
+        <label for="{{ form.derived_project_name.id }}"
+               class="col-sm-3 col-form-label text-sm-end">{{ 
form.derived_project_name.label.text }}:</label>
+        <div class="col-sm-8">
+          {{ form.derived_project_name(class_="form-control") }}
+          {% if form.derived_project_name.errors -%}
+            <span class="text-danger small">{{ 
form.derived_project_name.errors[0] }}</span>{%- endif %}
+            <p class="text-muted small mt-1">The desired suffix for the full 
project name.</p>
+          </div>
+        </div>
+
+        <div class="mb-3 pb-3 row border-bottom">
+          <label id="new-project-name-label"
+                 for="new-project-name-display"
+                 class="col-sm-3 col-form-label text-sm-end">New project 
name:</label>
+          <div class="col-sm-8">
+            <code id="new-project-name-display"
+                  class="form-control-plaintext bg-light p-2 rounded 
d-block"></code>
+            <p class="text-muted small mt-1">This will be the full display 
name for the derived project.</p>
+          </div>
+        </div>
+
+        <div class="mb-3 pb-3 row border-bottom">
+          <label id="new-project-label-label"
+                 for="new-project-label-display"
+                 class="col-sm-3 col-form-label text-sm-end">New project 
label:</label>
+          <div class="col-sm-8">
+            <code id="new-project-label-display"
+                  class="form-control-plaintext bg-light p-2 rounded 
d-block"></code>
+            <p class="text-muted small mt-1">This will be the short label used 
in URLs and identifiers.</p>
+          </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", () => {
+              const projectSelect = document.getElementById("{{ 
form.project_name.id }}");
+              const derivedNameInput = document.getElementById("{{ 
form.derived_project_name.id }}");
+              const newNameDisplay = 
document.getElementById("new-project-name-display");
+              const newLabelDisplay = 
document.getElementById("new-project-label-display");
+
+              if (!projectSelect || !derivedNameInput || !newNameDisplay || 
!newLabelDisplay) return;
+
+              function generateSlug(text) {
+                  return text.toLowerCase().replace(/\s+/g, 
"-").replace(/[^a-z0-9-]/g, "");
+              }
+
+              function updatePreview() {
+                  const selectedOption = 
projectSelect.options[projectSelect.selectedIndex];
+                  const baseLabel = selectedOption ? selectedOption.value : "";
+                  const baseFullName = selectedOption ? selectedOption.text : 
"";
+                  const derivedNameValue = derivedNameInput.value.trim();
+
+                  let newFullName = baseFullName;
+                  if (derivedNameValue) {
+                      const match = baseFullName.match(/^(.*?)\s*(\(.*\))?$/);
+                      let mainPart = baseFullName.trim();
+                      let suffixPart = null;
+
+                      if (match) {
+                          mainPart = match[1] ? match[1].trim() : mainPart;
+                          suffixPart = match[2];
+                      }
+
+                      if (suffixPart) {
+                          newFullName = `${mainPart} ${derivedNameValue} 
${suffixPart}`;
+                      } else {
+                          newFullName = `${mainPart} ${derivedNameValue}`;
+                      }
+                      newFullName = newFullName.replace(/\s{2,}/g, " ").trim();
+                  }
+                  newNameDisplay.textContent = newFullName || "(Select base 
project)";
+
+                  let newLabel = baseLabel;
+                  if (derivedNameValue) {
+                      const derivedSlug = generateSlug(derivedNameValue);
+                      if (derivedSlug) {
+                          newLabel = `${baseLabel}-${derivedSlug}`;
+                      }
+                  }
+                  newLabelDisplay.textContent = newLabel || "(Enter derived 
project name)";
+              }
+
+              projectSelect.addEventListener("change", updatePreview);
+              derivedNameInput.addEventListener("input", updatePreview);
+
+              updatePreview();
+          });
+      </script>
+    {% endblock javascripts %}
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 2610f94..a630088 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -20,6 +20,15 @@
     {% endif %}
   </div>
 
+  <div class="card mb-4">
+    <div class="card-header bg-light">
+      <h3 class="mb-2">Project label</h3>
+    </div>
+    <div class="card-body">
+      <code class="fs-6">{{ project.name }}</code>
+    </div>
+  </div>
+
   {% set is_admin = is_admin_fn(current_user.uid) %}
   {% set is_committee_member = is_committee_member_fn(project.committee, 
current_user.uid) %}
 
diff --git a/atr/templates/vote-policy-form.html 
b/atr/templates/vote-policy-form.html
index 5302e5b..5d83ad3 100644
--- a/atr/templates/vote-policy-form.html
+++ b/atr/templates/vote-policy-form.html
@@ -1,7 +1,7 @@
 
 <form method="post"
       enctype="multipart/form-data"
-      class="striking py-4 px-5 needs-validation"
+      class="atr-canary py-4 px-5 needs-validation"
       novalidate>
   <input type="hidden" name="form_type" value="single" />
   {{ form.hidden_tag() }}


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

Reply via email to