asf-tooling commented on issue #1254:
URL:
https://github.com/apache/tooling-trusted-releases/issues/1254#issuecomment-4495849198
<!-- gofannon-issue-triage-bot v2 -->
**Automated triage** — analyzed at `main@ab610b23`
**Type:** `new_feature` • **Classification:** `actionable` •
**Confidence:** `high`
**Application domain(s):** `project_and_committee_management`,
`web_interface_and_api`
### Summary
The issue requests the ability to edit a project's display name after
creation. Currently, `display_name` is only settable via `AddProjectForm` at
creation time. The web UI has no edit form for it, though the API path
(`_apply_project_args_no_commit`) already supports updating the `name` field.
Implementation requires a new form, writer method, POST handler, and UI
section. @dave2wave raised data integrity concerns; analysis shows the `name`
field is purely cosmetic — the `key` field is the primary key used in all
foreign key relationships, so renaming has no structural impact.
### Where this lives in the code today
#### `atr/shared/projects.py` — `AddProjectForm` (lines 45-68)
_currently does this_
Contains the display_name validation logic that should be reused for the
edit form.
```python
class AddProjectForm(form.Form):
committee_key: safe.CommitteeKey = 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_key = str(self.committee_key)
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)
```
#### `atr/shared/projects.py` — `EditMetadataForm` (lines 206-220)
_extension point_
Existing metadata edit form that does NOT include display_name; the new form
will be a sibling to this.
```python
class EditMetadataForm(form.Form):
variant: EDIT_METADATA = form.value(EDIT_METADATA)
project_key: safe.ProjectKey = form.label("Project name",
widget=form.Widget.HIDDEN)
homepage: form.OptionalURL = form.label(
"Homepage",
"Project website URL.",
)
lifecycle_page: form.OptionalURL = form.label(
"Lifecycle page",
"URL of the page describing this project's release support and
lifecycle plans.",
)
download_page: form.OptionalURL = form.label(
"Download page",
"URL of the project's official download page.",
)
```
#### `atr/storage/writers/project.py` — `CommitteeMember.edit_metadata`
(lines 279-298)
_extension point_
The writer for metadata edits; a similar `rename` method needs to be added
next to it.
```python
async def edit_metadata(self, form: shared.projects.EditMetadataForm) ->
None:
project = await self.__data.project(key=str(form.project_key)).get()
if not project:
raise storage.AccessError(f"Project '{form.project_key}' not
found.", status=404)
project.homepage = str(form.homepage) if form.homepage else None
project.lifecycle_page = str(form.lifecycle_page) if
form.lifecycle_page else None
project.download_page = str(form.download_page) if
form.download_page else None
project.bug_database = str(form.bug_database) if form.bug_database
else None
project.mailing_lists = str(form.mailing_lists) if
form.mailing_lists else None
project.repository = list(form.repository)
project.standards = list(form.standards)
project.updated = datetime.datetime.now(datetime.UTC)
project.updated_by = self.__asf_uid
await self.__data.commit()
self.__write_as.append_to_audit_log(
asf_uid=self.__asf_uid,
project_key=str(project.key),
)
```
#### `atr/storage/writers/project.py` —
`CommitteeMember._apply_project_args_no_commit` (lines 407-431)
_currently does this_
The API already supports updating `name` (display name) via str_fields,
confirming there are no structural constraints on changing it.
```python
async def _apply_project_args_no_commit(
self,
project: sql.Project,
args: api.ProjectConfigProjectArgs,
) -> None:
str_fields = {
"name",
"description",
"short_description",
"homepage",
"lifecycle_page",
"download_page",
"bug_database",
"mailing_lists",
"version_pattern",
"cycle_match",
"branch_template",
}
list_fields = {"repository", "standards"}
...
for field in str_fields & provided:
value = getattr(args, field)
if value is not None:
value = str(value).strip() or None
setattr(project, field, value)
```
#### `atr/models/sql.py` — `Project` (lines 738-748)
_currently does this_
The Project model shows `name` is a simple nullable string field (not a
key), confirming renaming has no FK implications.
```python
class Project(sqlmodel.SQLModel, table=True):
key: str = sqlmodel.Field(primary_key=True, unique=True,
**example("example"))
# TODO: Ideally full_name would be unique for str only, but that's
complex
# We always include "Apache" in the full_name
name: str | None = sqlmodel.Field(default=None, **example("Apache
Example"))
status: ProjectStatus = sqlmodel.Field(default=ProjectStatus.ACTIVE,
**example(ProjectStatus.ACTIVE))
# M-1: Project -> Project
# 1-M: (Project.child_project is missing, would be Project -> [Project])
super_project_key: str | None = sqlmodel.Field(default=None,
foreign_key="project.key")
```
### Where new code would go
- `atr/shared/projects.py` — after symbol EditMetadataForm
Add EditDisplayNameForm class with display name validation
- `atr/storage/writers/project.py` — after symbol
CommitteeMember.edit_metadata
Add rename() method to persist display name changes
- `atr/post/projects.py` — after symbol _process_edit_metadata_form
Add _process_edit_display_name_form handler
- `atr/get/projects.py` — after symbol _render_metadata_form
Add _render_display_name_form to show edit UI in the metadata tab
### Proposed approach
Add a new `EditDisplayNameForm` to `atr/shared/projects.py` with validation
logic extracted from `AddProjectForm` (must start with 'Apache', title case,
etc.). Add a `rename()` method to `CommitteeMember` in
`atr/storage/writers/project.py` that updates `project.name`, sets
`updated`/`updated_by`, commits, and logs the change. Wire it through the POST
handler in `atr/post/projects.py` and register in `_VIEW_HANDLERS`. Finally,
render the form in the metadata tab of the project detail page.
Regarding @dave2wave's data integrity concern: the `project.key` (primary
key, used in all FKs) is unchanged by this operation. The `name` field is
purely cosmetic/display. The API already supports this via
`_apply_project_args_no_commit`. Historical email subjects/announcements will
retain the old name (sent at time of creation), which is acceptable behavior.
The only implication is that display references across the UI update
immediately — no cascading integrity issues.
### Suggested patches
#### `atr/shared/projects.py`
Add EDIT_DISPLAY_NAME type alias, EditDisplayNameForm with validation, and
include it in ProjectViewForm union
````diff
--- a/atr/shared/projects.py
+++ b/atr/shared/projects.py
@@ -28,6 +28,7 @@ import atr.util as util
type COMPOSE = Literal["compose"]
type EDIT_CYCLE_DATES = Literal["edit_cycle_dates"]
+type EDIT_DISPLAY_NAME = Literal["edit_display_name"]
type EDIT_METADATA = Literal["edit_metadata"]
type EDIT_VERSION_SCHEME = Literal["edit_version_scheme"]
type FINISH = Literal["finish"]
@@ -104,6 +105,43 @@ class AddProjectForm(form.Form):
return self
+class EditDisplayNameForm(form.Form):
+ variant: EDIT_DISPLAY_NAME = form.value(EDIT_DISPLAY_NAME)
+ project_key: safe.ProjectKey = form.label("Project 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.',
+ )
+
+ @pydantic.model_validator(mode="after")
+ def validate_display_name(self) -> EditDisplayNameForm:
+ display_name = self.display_name.strip()
+ display_name = re.sub(r" +", " ", display_name)
+ object.__setattr__(self, "display_name", display_name)
+
+ display_name_words = display_name.split(" ")
+ if display_name_words[0] != "Apache":
+ raise ValueError("The first display name word must be
'Apache'.")
+
+ if not display_name_words[1:]:
+ raise ValueError("The display name must have at least two
words.")
+
+ 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
+ if not (r_pascal_case.match(display_name_word) or
r_camel_case.match(display_name_word) or r_mod_case.match(display_name_word)):
+ raise ValueError("Display name words must be in PascalCase,
camelCase, or mod_ case.")
+
+ if not display_name.replace(" ", "").replace(".", "").replace("+",
"").isalnum():
+ raise ValueError("Display name must be alphanumeric and may
include spaces or dots or plus signs.")
+
+ return self
+
+
class ComposePolicyForm(form.Form):
variant: COMPOSE = form.value(COMPOSE)
project_key: safe.ProjectKey = form.label("Project name",
widget=form.Widget.HIDDEN)
@@ -308,6 +346,7 @@ type ProjectViewForm = Annotated[
ComposePolicyForm
| EditCycleDatesForm
+ | EditDisplayNameForm
| EditMetadataForm
| EditVersionSchemeForm
| FinishPolicyForm
````
#### `atr/storage/writers/project.py`
Add rename() method to CommitteeMember that updates project.name with audit
logging
````diff
--- a/atr/storage/writers/project.py
+++ b/atr/storage/writers/project.py
@@ -265,6 +265,22 @@ class CommitteeMember(CommitteeParticipant):
project_key=str(project.key),
)
+ async def rename(self, project_key: safe.ProjectKey, new_display_name:
str) -> None:
+ project = await self.__data.project(key=str(project_key)).get()
+ if not project:
+ raise storage.AccessError(f"Project '{project_key}' not
found.", status=404)
+ storage.ensure_project_active(project)
+ project.name = new_display_name
+ project.updated = datetime.datetime.now(datetime.UTC)
+ project.updated_by = self.__asf_uid
+ await self.__data.commit()
+ self.__write_as.append_to_audit_log(
+ asf_uid=self.__asf_uid,
+ project_key=str(project_key),
+ new_display_name=new_display_name,
+ )
+
async def language_add(self, project_key: safe.ProjectKey,
new_language: str) -> bool:
project = await self.__data.project(key=str(project_key)).get()
if not project:
````
#### `atr/post/projects.py`
Add handler for EditDisplayNameForm and register it in _VIEW_HANDLERS
````diff
--- a/atr/post/projects.py
+++ b/atr/post/projects.py
@@ -216,6 +216,25 @@ async def _process_edit_metadata_form(
return await session.redirect(
get.projects.view, project_key=str(project_key), tab="metadata",
success="Metadata saved."
)
+async def _process_edit_display_name_form(
+ session: web.Committer, edit_form: shared.projects.EditDisplayNameForm
+) -> web.WerkzeugResponse:
+ project_key = edit_form.project_key
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_key)
+ try:
+ await wacm.project.rename(project_key, edit_form.display_name)
+ except storage.AccessError as e:
+ return await session.redirect(
+ get.projects.view,
+ project_key=str(project_key),
+ tab="metadata",
+ error=f"Error renaming project: {e}",
+ )
+
+ return await session.redirect(
+ get.projects.view, project_key=str(project_key), tab="metadata",
success="Display name updated."
+ )
+
+
async def _process_edit_version_scheme_form(
@@ -310,6 +329,7 @@ _VIEW_HANDLERS: Final[dict[type,
Callable[[web.Committer, Any], Awaitable[web.We
shared.projects.AddCategoryForm: _process_add_category,
shared.projects.AddLanguageForm: _process_add_language,
shared.projects.ComposePolicyForm: _process_compose_form,
shared.projects.DeleteProjectForm: _process_delete_project,
shared.projects.EditCycleDatesForm: _process_edit_cycle_dates_form,
+ shared.projects.EditDisplayNameForm: _process_edit_display_name_form,
shared.projects.EditMetadataForm: _process_edit_metadata_form,
shared.projects.EditVersionSchemeForm:
_process_edit_version_scheme_form,
shared.projects.FinishPolicyForm: _process_finish_form,
````
#### `atr/get/projects.py`
Add _render_display_name_form and include it in the metadata tab for
privileged users
````diff
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -420,10 +420,30 @@ async def _render_metadata_form(project: sql.Project)
-> htm.Element:
return card.collect()
+async def _render_display_name_form(project: sql.Project) -> htm.Element:
+ card = htm.Block(htm.div, classes=".card.mb-4")
+ card.div(".card-header.bg-light")[htm.h3(".mb-0")["Display name"]]
+ with card.block(htm.div, classes=".card-body") as body:
+ await form.render_block(
+ body,
+ model_cls=shared.projects.EditDisplayNameForm,
+ action=_view_action(project, "metadata"),
+ submit_label="Save",
+ defaults={
+ "project_key": str(project.key),
+ "display_name": project.name or "",
+ },
+ )
+ return card.collect()
+
+
async def _render_metadata_tab(project: sql.Project, *, can_edit: bool) ->
htm.Element:
block = htm.Block()
if can_edit:
+ block.append(await _render_display_name_form(project))
block.append(await _render_metadata_form(project))
block.append(_render_categories_section(project))
block.append(_render_languages_section(project))
else:
block.append(_render_metadata_card(project))
return block.collect()
````
### Open questions
- Data integrity: @dave2wave's concern about whether renaming has
implications beyond display — analysis suggests it's safe since `project.key`
(not `name`) is used for all FK relationships, but this should be explicitly
confirmed by the team.
- Should the display name be unique across all projects? Currently the model
has no uniqueness constraint on `name`, and the API already allows changing it
freely.
- Should there be a `display_name` field shown in the read-only metadata
card for non-editors, or is the h1 heading sufficient?
- The `Project.display_name` property is in the truncated portion of sql.py
— confirm it simply returns `self.name` (or derives from it) so `project.name =
new_value` correctly updates what's displayed.
### Files examined
- `atr/get/projects.py`
- `atr/post/projects.py`
- `atr/shared/projects.py`
- `atr/storage/writers/project.py`
- `atr/models/sql.py`
- `atr/templates/projects.html`
- `atr/get/committees.py`
- `atr/templates/project-select.html`
### Related issues
This issue appears related to: #1252.
_Both request making project metadata editable in the UI (display name and
description respectively)_
---
*Draft from a triage agent. A human reviewer should validate before merging
any change. The agent did not run tests or verify diffs apply.*
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]