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-releases.git
The following commit(s) were added to refs/heads/main by this push:
new ff64446 Make the form to add a project more type safe
ff64446 is described below
commit ff6444651484aa8dd23a76bf8cb1b774ac632e58
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Nov 14 19:47:55 2025 +0000
Make the form to add a project more type safe
---
atr/get/projects.py | 48 +++++++-
atr/post/projects.py | 21 +++-
atr/shared/projects.py | 193 +++++++++++----------------------
atr/templates/project-add-project.html | 50 ---------
4 files changed, 131 insertions(+), 181 deletions(-)
diff --git a/atr/get/projects.py b/atr/get/projects.py
index eedd393..9d72441 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import asfquart.base as base
import htpy
+import markupsafe
import atr.blueprints.get as get
import atr.config as config
@@ -45,7 +46,52 @@ from atr.get import candidate
@get.committer("/project/add/<committee_name>")
async def add_project(session: web.Committer, committee_name: str) ->
web.WerkzeugResponse | str:
- return await shared.projects.add_project(session, committee_name)
+ await session.check_access_committee(committee_name)
+
+ async with db.session() as data:
+ committee = await data.committee(name=committee_name).demand(
+ base.ASFQuartException(f"Committee {committee_name} not found",
errorcode=404)
+ )
+
+ page = htm.Block()
+ page.p[htm.a(".atr-back-link", href=util.as_url(committees.view,
name=committee_name))["← Back to committee"]]
+ page.h1["Add project"]
+ page.p[f"Add a new project to the {committee.display_name} committee."]
+
+ committee_display_name = committee.full_name or committee_name.title()
+
+ form.render_block(
+ page,
+ model_cls=shared.projects.AddProjectForm,
+ action=util.as_url(post.projects.add_project,
committee_name=committee_name),
+ submit_label="Add project",
+ cancel_url=util.as_url(committees.view, name=committee_name),
+ defaults={
+ "committee_name": committee_name,
+ },
+ )
+
+ script_text = markupsafe.Markup(f"""
+document.addEventListener("DOMContentLoaded", function() {{
+ const committeeInput =
document.querySelector('input[name="committee_name"]');
+ if (committeeInput) {{
+ const committeeName = committeeInput.value;
+ const committeeDisplayName = "{committee_display_name}";
+ const formTexts = document.querySelectorAll('.form-text, .text-muted');
+ formTexts.forEach(function(element) {{
+ element.textContent = element.textContent.replace(/Example/g,
committeeDisplayName);
+ element.textContent = element.textContent.replace(/example/g,
committeeName.toLowerCase());
+ }});
+ }}
+}});
+""")
+ page.append(htm.script[script_text])
+
+ return await template.blank(
+ title="Add project",
+ description=f"Add a new project to the {committee.display_name}
committee.",
+ content=page.collect(),
+ )
@get.public("/projects")
diff --git a/atr/post/projects.py b/atr/post/projects.py
index 80d437b..466b579 100644
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -32,8 +32,25 @@ import atr.web as web
@post.committer("/project/add/<committee_name>")
-async def add_project(session: web.Committer, committee_name: str) ->
web.WerkzeugResponse | str:
- return await shared.projects.add_project(session, committee_name)
[email protected](shared.projects.AddProjectForm)
+async def add_project(
+ session: web.Committer, project_form: shared.projects.AddProjectForm,
committee_name: str
+) -> web.WerkzeugResponse:
+ display_name = project_form.display_name
+ label = project_form.label
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(committee_name)
+ try:
+ await wacm.project.create(committee_name, display_name, label)
+ except storage.AccessError as e:
+ return await session.redirect(
+ get.projects.add_project, committee_name=committee_name,
error=f"Error adding project: {e}"
+ )
+
+ return await session.redirect(
+ get.projects.view, name=label, success=f"Project '{display_name}'
added successfully."
+ )
@post.committer("/project/delete")
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
index 014c62a..a7d0061 100644
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -20,18 +20,10 @@ from __future__ import annotations
import re
from typing import Annotated, Literal
-import asfquart.base as base
import pydantic
-import quart
-import atr.db as db
import atr.form as form
-import atr.forms as forms
-import atr.get as get
-import atr.storage as storage
-import atr.template as template
import atr.util as util
-import atr.web as web
type COMPOSE = Literal["compose"]
type VOTE = Literal["vote"]
@@ -43,11 +35,71 @@ type REMOVE_LANGUAGE = Literal["remove_language"]
type DELETE_PROJECT = Literal["delete_project"]
-class AddForm(forms.Typed):
- committee_name = forms.hidden()
- display_name = forms.string("Display name")
- label = forms.string("Label")
- submit = forms.submit("Add project")
+class AddProjectForm(form.Form):
+ committee_name: str = form.label("Committee name",
widget=form.Widget.HIDDEN)
+ display_name: str = form.label(
+ "Display name",
+ 'For example, "Apache Example" or "Apache Example Components". '
+ 'You must start with "Apache " and you must use title case.',
+ )
+ label: str = form.label(
+ "Label",
+ 'For example, "example" or "example-components". '
+ "You must start with your committee label, and you must use lower
case.",
+ )
+
+ @pydantic.model_validator(mode="after")
+ def validate_fields(self) -> AddProjectForm:
+ committee_name = self.committee_name
+ display_name = self.display_name.strip()
+ label = self.label.strip()
+
+ # Normalise spaces in the display name
+ display_name = re.sub(r" +", " ", display_name)
+
+ # We must use object.__setattr__ to avoid calling the model validator
again
+ object.__setattr__(self, "display_name", display_name)
+
+ # Validate display name starts with "Apache"
+ display_name_words = display_name.split(" ")
+ if display_name_words[0] != "Apache":
+ raise ValueError("The first display name word must be 'Apache'.")
+
+ # Validate display name has at least two words
+ if not display_name_words[1:]:
+ raise ValueError("The display name must have at least two words.")
+
+ # Validate display name uses correct case
+ allowed_irregular_words = {".NET", "C++", "Empire-db", "Lucene.NET",
"for", "jclouds"}
+ r_pascal_case = re.compile(r"^([A-Z][0-9a-z]*)+$")
+ r_camel_case = re.compile(r"^[a-z]*([A-Z][0-9a-z]*)+$")
+ r_mod_case = re.compile(r"^mod(_[0-9a-z]+)+$")
+ for display_name_word in display_name_words[1:]:
+ if display_name_word in allowed_irregular_words:
+ continue
+ is_pascal_case = r_pascal_case.match(display_name_word)
+ is_camel_case = r_camel_case.match(display_name_word)
+ is_mod_case = r_mod_case.match(display_name_word)
+ if not (is_pascal_case or is_camel_case or is_mod_case):
+ raise ValueError("Display name words must be in PascalCase,
camelCase, or mod_ case.")
+
+ # Validate display name is alphanumeric with spaces, dots, and plus
signs
+ if not display_name.replace(" ", "").replace(".", "").replace("+",
"").isalnum():
+ raise ValueError("Display name must be alphanumeric and may
include spaces or dots or plus signs.")
+
+ # Validate label starts with committee name
+ if not (label.startswith(committee_name + "-") or (label ==
committee_name)):
+ raise ValueError(f"Label must be '{committee_name}' or start with
'{committee_name}-'.")
+
+ # Validate label is lowercase
+ if not label.islower():
+ raise ValueError("Label must be all lower case.")
+
+ # Validate label is alphanumeric with hyphens
+ if not label.replace("-", "").isalnum():
+ raise ValueError("Label must be alphanumeric and may include
hyphens.")
+
+ return self
class ComposePolicyForm(form.Form):
@@ -223,118 +275,3 @@ type ProjectViewForm = Annotated[
| DeleteProjectForm,
form.DISCRIMINATOR,
]
-
-
-async def add_project(session: web.Committer, committee_name: str) ->
web.WerkzeugResponse | str:
- await session.check_access_committee(committee_name)
-
- async with db.session() as data:
- committee = await data.committee(name=committee_name).demand(
- base.ASFQuartException(f"Committee {committee_name} not found",
errorcode=404)
- )
-
- form = await AddForm.create_form(data={"committee_name": committee_name})
- form.display_name.description = f"""\
-For example, "Apache {committee.display_name}" or "Apache
{committee.display_name} Components".
-You must start with "Apache " and you must use title case.
-"""
- form.label.description = f"""\
-For example, "{committee.name}" or "{committee.name}-components".
-You must start with your committee label, and you must use lower case.
-"""
-
- if await form.validate_on_submit():
- return await _project_add(form, session)
-
- return await template.render("project-add-project.html", form=form,
committee_name=committee.display_name)
-
-
-async def _project_add(form: AddForm, session: web.Committer) ->
web.WerkzeugResponse:
- form_values = await _project_add_validate(form)
- if form_values is None:
- return quart.redirect(util.as_url(get.projects.add_project,
committee_name=form.committee_name.data))
- committee_name, display_name, label = form_values
-
- async with storage.write(session) as write:
- wacm = await write.as_project_committee_member(committee_name)
- try:
- await wacm.project.create(committee_name, display_name, label)
- except storage.AccessError as e:
- await quart.flash(f"Error adding project: {e}", "error")
- return quart.redirect(util.as_url(get.projects.add_project,
committee_name=committee_name))
-
- return quart.redirect(util.as_url(get.projects.view, name=label))
-
-
-async def _project_add_validate(form: AddForm) -> tuple[str, str, str] | None:
- committee_name = str(form.committee_name.data)
- # Normalise spaces in the display name, then validate
- display_name = str(form.display_name.data).strip()
- display_name = re.sub(r" +", " ", display_name)
- if not await _project_add_validate_display_name(display_name):
- return None
- # Hidden criterion!
- # $ sqlite3 state/atr.db 'select full_name from project;' | grep --
'[^A-Za-z0-9 ]'
- # Apache .NET Ant Library
- # Apache Oltu - Parent
- # Apache Commons Chain (Dormant)
- # Apache Commons Functor (Dormant)
- # Apache Commons OGNL (Dormant)
- # Apache Commons Proxy (Dormant)
- # Apache Empire-db
- # Apache mod_ftp
- # Apache Lucene.Net
- # Apache mod_perl
- # Apache Xalan for C++ XSLT Processor
- # Apache Xerces for C++ XML Parser
- if not display_name.replace(" ", "").replace(".", "").replace("+",
"").isalnum():
- await quart.flash("Display name must be alphanumeric and may include
spaces or dots or plus signs", "error")
- return None
-
- label = str(form.label.data).strip()
- if not (label.startswith(committee_name + "-") or (label ==
committee_name)):
- await quart.flash(f"Label must start with '{committee_name}-'",
"error")
- return None
- if not label.islower():
- await quart.flash("Label must be all lower case", "error")
- return None
- # Hidden criterion!
- if not label.replace("-", "").isalnum():
- await quart.flash("Label must be alphanumeric and may include
hyphens", "error")
- return None
-
- return (committee_name, display_name, label)
-
-
-async def _project_add_validate_display_name(display_name: str) -> bool:
- # We have three criteria for display names
- must_start_apache = "The first display name word must be 'Apache'."
- must_have_two_words = "The display name must have at least two words."
- must_use_correct_case = "Display name words must be in PascalCase,
camelCase, or mod_ case."
-
- # First criterion, the first word must be "Apache"
- display_name_words = display_name.split(" ")
- if display_name_words[0] != "Apache":
- await quart.flash(must_start_apache, "error")
- return False
-
- # Second criterion, the display name must have two or more words
- if not display_name_words[1:]:
- await quart.flash(must_have_two_words, "error")
- return False
-
- # Third criterion, the display name must use the correct case
- allowed_irregular_words = {".NET", "C++", "Empire-db", "Lucene.NET",
"for", "jclouds"}
- r_pascal_case = re.compile(r"^([A-Z][0-9a-z]*)+$")
- r_camel_case = re.compile(r"^[a-z]*([A-Z][0-9a-z]*)+$")
- r_mod_case = re.compile(r"^mod(_[0-9a-z]+)+$")
- for display_name_word in display_name_words[1:]:
- if display_name_word in allowed_irregular_words:
- continue
- is_pascal_case = r_pascal_case.match(display_name_word)
- is_camel_case = r_camel_case.match(display_name_word)
- is_mod_case = r_mod_case.match(display_name_word)
- if not (is_pascal_case or is_camel_case or is_mod_case):
- await quart.flash(must_use_correct_case, "error")
- return False
- return True
diff --git a/atr/templates/project-add-project.html
b/atr/templates/project-add-project.html
deleted file mode 100644
index 6c2f693..0000000
--- a/atr/templates/project-add-project.html
+++ /dev/null
@@ -1,50 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
- Add a new project ~ ATR
-{% endblock title %}
-
-{% block description %}
- Add a new project.
-{% endblock description %}
-
-{% block content %}
- <h1>
- Add a new <strong>{{ committee_name }}</strong> project
- </h1>
- {{ forms.errors_summary(form) }}
-
- <form method="post" class="atr-canary py-4" novalidate>
- {{ form.hidden_tag() }}
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(form.display_name, col="sm3") }}
- <div class="col-sm-8">
- {{ forms.widget(form.display_name, id=form.display_name.id) }}
- {{ forms.errors(form.display_name, classes="text-danger small") }}
- {{ forms.description(form.display_name, classes="text-muted mt-1") }}
- <p id="capitalisation-warning" class="text-danger small mt-1 d-none">
- <i class="bi bi-exclamation-triangle"></i>
- Warning: Ensure all words in the derived name start with a capital
for proper display.
- </p>
- </div>
- </div>
-
- <div class="mb-3 pb-3 row border-bottom">
- {{ forms.label(form.label, col="sm3") }}
- <div class="col-sm-8">
- {{ forms.widget(form.label, id=form.label.id) }}
- {{ forms.errors(form.label, classes="text-danger small") }}
- {{ forms.description(form.label, classes="text-muted mt-1") }}
- <p id="capitalisation-warning" class="text-danger small mt-1 d-none">
- <i class="bi bi-exclamation-triangle"></i>
- Warning: Ensure all words in the derived name start with a capital
for proper display.
- </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 %}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]