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 fb38bc4 Move keys, preview, and projects routes to the new layout
fb38bc4 is described below
commit fb38bc46cc1ff1b0b5128e36f6dcaf3970cd0ab9
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Oct 28 15:22:12 2025 +0000
Move keys, preview, and projects routes to the new layout
---
atr/admin/__init__.py | 23 +-
atr/get/__init__.py | 6 +
atr/get/keys.py | 102 +++++
atr/get/preview.py | 83 ++++
atr/get/projects.py | 76 ++++
atr/post/__init__.py | 6 +
atr/post/keys.py | 126 ++++++
atr/{routes => post}/preview.py | 79 +---
atr/post/projects.py | 64 +++
atr/routes/__init__.py | 6 -
atr/shared/__init__.py | 4 +
atr/{routes => shared}/keys.py | 136 +------
atr/shared/projects.py | 538 +++++++++++++++++++++++++
atr/templates/announce-selected.html | 4 +-
atr/templates/check-selected-path-table.html | 2 +-
atr/templates/check-selected-release-info.html | 2 +-
atr/templates/committee-directory.html | 6 +-
atr/templates/committee-view.html | 9 +-
atr/templates/file-selected-path.html | 2 +-
atr/templates/finish-selected.html | 2 +-
atr/templates/includes/sidebar.html | 6 +-
atr/templates/index-committer.html | 4 +-
atr/templates/keys-add.html | 6 +-
atr/templates/keys-details.html | 2 +-
atr/templates/keys-review.html | 18 +-
atr/templates/keys-ssh-add.html | 2 +-
atr/templates/keys-upload.html | 4 +-
atr/templates/phase-view.html | 2 +-
atr/templates/project-view.html | 12 +-
atr/templates/projects.html | 4 +-
atr/templates/release-select.html | 2 +-
atr/templates/upload-selected.html | 2 +-
atr/templates/user-ssh-keys.html | 8 +-
atr/templates/voting-selected-revision.html | 2 +-
34 files changed, 1076 insertions(+), 274 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 4c9b1fd..d0835e5 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -40,6 +40,7 @@ import atr.datasources.apache as apache
import atr.db as db
import atr.db.interaction as interaction
import atr.forms as forms
+import atr.get as get
import atr.ldap as ldap
import atr.log as log
import atr.models.sql as sql
@@ -291,29 +292,21 @@ async def data(session: web.Committer, model: str =
"Committee") -> str:
@admin.get("/delete-test-openpgp-keys")
async def delete_test_openpgp_keys_get(session: web.Committer) ->
quart.Response | response.Response:
- return await _delete_test_openpgp_keys(session)
+ if not config.get().ALLOW_TESTS:
+ raise base.ASFQuartException("Test operations are disabled in this
environment", errorcode=403)
+
+ delete_form = await DeleteTestKeysForm.create_form()
+ rendered_form = forms.render_simple(delete_form, action="")
+ return web.ElementResponse(rendered_form)
@admin.post("/delete-test-openpgp-keys")
async def delete_test_openpgp_keys_post(session: web.Committer) ->
quart.Response | response.Response:
- return await _delete_test_openpgp_keys(session)
-
-
-async def _delete_test_openpgp_keys(session: web.Committer) -> quart.Response
| response.Response:
"""Delete all test user OpenPGP keys and their links."""
- import atr.routes
-
if not config.get().ALLOW_TESTS:
raise base.ASFQuartException("Test operations are disabled in this
environment", errorcode=403)
test_uid = "test"
-
- if quart.request.method != "POST":
- delete_form = await DeleteTestKeysForm.create_form()
- rendered_form = forms.render_simple(delete_form, action="")
- return web.ElementResponse(rendered_form)
-
- # This is a POST request
delete_form = await DeleteTestKeysForm.create_form()
if not await delete_form.validate_on_submit():
raise base.ASFQuartException("Invalid form submission. Please check
your input and try again.", errorcode=400)
@@ -323,7 +316,7 @@ async def _delete_test_openpgp_keys(session: web.Committer)
-> quart.Response |
outcome = await wafc.keys.test_user_delete_all(test_uid)
outcome.result_or_raise()
- return await session.redirect(atr.routes.keys.keys)
+ return await session.redirect(get.keys.keys)
@admin.get("/delete-committee-keys")
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 5c2c1b4..1c3a872 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -28,6 +28,9 @@ import atr.get.draft as draft
import atr.get.file as file
import atr.get.finish as finish
import atr.get.ignores as ignores
+import atr.get.keys as keys
+import atr.get.preview as preview
+import atr.get.projects as projects
import atr.get.vote as vote
ROUTES_MODULE: Final[Literal[True]] = True
@@ -44,5 +47,8 @@ __all__ = [
"file",
"finish",
"ignores",
+ "keys",
+ "preview",
+ "projects",
"vote",
]
diff --git a/atr/get/keys.py b/atr/get/keys.py
new file mode 100644
index 0000000..d3a5a54
--- /dev/null
+++ b/atr/get/keys.py
@@ -0,0 +1,102 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""keys.py"""
+
+import datetime
+
+import asfquart as asfquart
+import quart
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.get as get
+import atr.db as db
+import atr.route as route
+import atr.shared as shared
+import atr.storage as storage
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/keys/add")
+async def add(session: web.Committer) -> str:
+ """Add a new public signing key to the user's account."""
+ return await shared.keys.add(session)
+
+
[email protected]("/keys/details/<fingerprint>")
+async def details(session: web.Committer, fingerprint: str) -> str |
response.Response:
+ """Display details for a specific OpenPGP key."""
+ return await shared.keys.details(session, fingerprint)
+
+
[email protected]("/keys/export/<committee_name>")
+async def export(session: route.CommitterSession, committee_name: str) ->
web.TextResponse:
+ """Export a KEYS file for a specific committee."""
+ async with storage.write() as write:
+ wafc = write.as_foundation_committer()
+ keys_file_text = await wafc.keys.keys_file_text(committee_name)
+
+ return web.TextResponse(keys_file_text)
+
+
[email protected]("/keys")
+async def keys(session: route.CommitterSession) -> str:
+ """View all keys associated with the user's account."""
+ committees_to_query = list(set(session.committees + session.projects))
+
+ delete_form = await shared.keys.DeleteKeyForm.create_form()
+ update_committee_keys_form = await
shared.keys.UpdateCommitteeKeysForm.create_form()
+
+ async with db.session() as data:
+ user_keys = await
data.public_signing_key(apache_uid=session.uid.lower(), _committees=True).all()
+ user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
+ user_committees_with_keys = await
data.committee(name_in=committees_to_query, _public_signing_keys=True).all()
+ for key in user_keys:
+ key.committees.sort(key=lambda c: c.name)
+
+ status_message = quart.request.args.get("status_message")
+ status_type = quart.request.args.get("status_type")
+
+ return await template.render(
+ "keys-review.html",
+ asf_id=session.uid,
+ user_keys=user_keys,
+ user_ssh_keys=user_ssh_keys,
+ committees=user_committees_with_keys,
+ algorithms=shared.algorithms,
+ status_message=status_message,
+ status_type=status_type,
+ now=datetime.datetime.now(datetime.UTC),
+ delete_form=delete_form,
+ update_committee_keys_form=update_committee_keys_form,
+ email_from_key=util.email_from_uid,
+ committee_is_standing=util.committee_is_standing,
+ )
+
+
[email protected]("/keys/ssh/add")
+async def ssh_add(session: web.Committer) -> response.Response | str:
+ """Add a new SSH key to the user's account."""
+ return await shared.keys.ssh_add(session)
+
+
[email protected]("/keys/upload")
+async def upload(session: web.Committer) -> str:
+ """Upload a KEYS file containing multiple OpenPGP keys."""
+ return await shared.keys.upload(session)
diff --git a/atr/get/preview.py b/atr/get/preview.py
new file mode 100644
index 0000000..0a9689b
--- /dev/null
+++ b/atr/get/preview.py
@@ -0,0 +1,83 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.get as get
+import atr.models.sql as sql
+import atr.template as template
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/preview/view/<project_name>/<version_name>")
+async def view(session: web.Committer, project_name: str, version_name: str)
-> response.Response | str:
+ """View all the files in the rsync upload directory for a release."""
+ await session.check_access(project_name)
+
+ release = await session.release(project_name, version_name,
phase=sql.ReleasePhase.RELEASE_PREVIEW)
+
+ # Convert async generator to list
+ # There must be a revision on a preview
+ file_stats = [
+ stat
+ async for stat in util.content_list(
+ util.get_unfinished_dir(), project_name, version_name,
release.unwrap_revision_number
+ )
+ ]
+ # Sort the files by FileStat.path
+ file_stats.sort(key=lambda fs: fs.path)
+
+ return await template.render(
+ # TODO: Move to somewhere appropriate
+ "phase-view.html",
+ file_stats=file_stats,
+ release=release,
+ format_datetime=util.format_datetime,
+ format_file_size=util.format_file_size,
+ format_permissions=util.format_permissions,
+ phase="release preview",
+ phase_key="preview",
+ )
+
+
[email protected]("/preview/view/<project_name>/<version_name>/<path:file_path>")
+async def view_path(
+ session: web.Committer, project_name: str, version_name: str, file_path:
str
+) -> response.Response | str:
+ """View the content of a specific file in the release preview."""
+ await session.check_access(project_name)
+
+ release = await session.release(project_name, version_name,
phase=sql.ReleasePhase.RELEASE_PREVIEW)
+ _max_view_size = 1 * 1024 * 1024
+ full_path = util.release_directory(release) / file_path
+ content_listing = await util.archive_listing(full_path)
+ content, is_text, is_truncated, error_message = await
util.read_file_for_viewer(full_path, _max_view_size)
+ return await template.render(
+ "file-selected-path.html",
+ release=release,
+ project_name=project_name,
+ version_name=version_name,
+ file_path=file_path,
+ content=content,
+ is_text=is_text,
+ is_truncated=is_truncated,
+ error_message=error_message,
+ format_file_size=util.format_file_size,
+ phase_key="preview",
+ content_listing=content_listing,
+ )
diff --git a/atr/get/projects.py b/atr/get/projects.py
new file mode 100644
index 0000000..5a6d682
--- /dev/null
+++ b/atr/get/projects.py
@@ -0,0 +1,76 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import atr.blueprints.get as get
+import atr.config as config
+import atr.db as db
+import atr.forms as forms
+import atr.models.sql as sql
+import atr.shared as shared
+import atr.template as template
+import atr.web as web
+
+if TYPE_CHECKING:
+ import werkzeug.wrappers.response as response
+
+
[email protected]("/project/add/<committee_name>")
+async def add_project(session: web.Committer, committee_name: str) ->
response.Response | str:
+ return await shared.projects.add_project(session, committee_name)
+
+
[email protected]("/projects")
+async def projects(session: web.Committer | None) -> str:
+ """Main project directory page."""
+ async with db.session() as data:
+ projects = await
data.project(_committee=True).order_by(sql.Project.full_name).all()
+ return await template.render("projects.html", projects=projects,
empty_form=await forms.Empty.create_form())
+
+
[email protected]("/project/select")
+async def select(session: web.Committer) -> str:
+ """Select a project to work on."""
+ user_projects = []
+ if session.uid:
+ async with db.session() as data:
+ # TODO: Move this filtering logic somewhere else
+ # The ALLOW_TESTS line allows test projects to be shown
+ conf = config.get()
+ all_projects = await data.project(status=sql.ProjectStatus.ACTIVE,
_committee=True).all()
+ user_projects = [
+ p
+ for p in all_projects
+ if p.committee
+ and (
+ (conf.ALLOW_TESTS and (p.committee.name == "test"))
+ or (session.uid in p.committee.committee_members)
+ or (session.uid in p.committee.committers)
+ or (session.uid in p.committee.release_managers)
+ )
+ ]
+ user_projects.sort(key=lambda p: p.display_name)
+
+ return await template.render("project-select.html",
user_projects=user_projects)
+
+
[email protected]("/projects/<name>")
+async def view(session: web.Committer, name: str) -> response.Response | str:
+ return await shared.projects.view(session, name)
diff --git a/atr/post/__init__.py b/atr/post/__init__.py
index be0f6c2..5e2dfe0 100644
--- a/atr/post/__init__.py
+++ b/atr/post/__init__.py
@@ -23,6 +23,9 @@ import atr.post.distribution as distribution
import atr.post.draft as draft
import atr.post.finish as finish
import atr.post.ignores as ignores
+import atr.post.keys as keys
+import atr.post.preview as preview
+import atr.post.projects as projects
import atr.post.vote as vote
ROUTES_MODULE: Final[Literal[True]] = True
@@ -34,5 +37,8 @@ __all__ = [
"draft",
"finish",
"ignores",
+ "keys",
+ "preview",
+ "projects",
"vote",
]
diff --git a/atr/post/keys.py b/atr/post/keys.py
new file mode 100644
index 0000000..246ef6f
--- /dev/null
+++ b/atr/post/keys.py
@@ -0,0 +1,126 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+
+import asfquart as asfquart
+import quart
+import werkzeug.wrappers.response as response
+
+import atr.blueprints.post as post
+import atr.get as get
+import atr.models.sql as sql
+import atr.shared as shared
+import atr.storage as storage
+import atr.storage.outcome as outcome
+import atr.storage.types as types
+import atr.util as util
+import atr.web as web
+
+
[email protected]("/keys/add")
+async def add(session: web.Committer) -> str:
+ """Add a new public signing key to the user's account."""
+ return await shared.keys.add(session)
+
+
[email protected]("/keys/delete")
+async def delete(session: web.Committer) -> response.Response:
+ """Delete a public signing key or SSH key from the user's account."""
+ form = await shared.keys.DeleteKeyForm.create_form(data=await
quart.request.form)
+
+ if not await form.validate_on_submit():
+ return await session.redirect(get.keys.keys, error="Invalid request
for key deletion.")
+
+ fingerprint = (await quart.request.form).get("fingerprint")
+ if not fingerprint:
+ return await session.redirect(get.keys.keys, error="Missing key
fingerprint for deletion.")
+
+ # Try to delete an SSH key first
+ # Otherwise, delete an OpenPGP key
+ # TODO: Unmerge this, or identify the key type
+ async with storage.write() as write:
+ wafc = write.as_foundation_committer()
+ try:
+ await wafc.ssh.delete_key(fingerprint)
+ except storage.AccessError:
+ pass
+ else:
+ return await session.redirect(get.keys.keys, success="SSH key
deleted successfully")
+ oc: outcome.Outcome[sql.PublicSigningKey] = await
wafc.keys.delete_key(fingerprint)
+
+ match oc:
+ case outcome.Result():
+ return await session.redirect(get.keys.keys, success="Key deleted
successfully")
+ case outcome.Error(error):
+ return await session.redirect(get.keys.keys, error=f"Error
deleting key: {error}")
+
+
[email protected]("/keys/details/<fingerprint>")
+async def details(session: web.Committer, fingerprint: str) -> str |
response.Response:
+ """Display details for a specific OpenPGP key."""
+ return await shared.keys.details(session, fingerprint)
+
+
[email protected]("/keys/import/<project_name>/<version_name>")
+async def import_selected_revision(session: web.Committer, project_name: str,
version_name: str) -> response.Response:
+ await util.validate_empty_form()
+
+ async with storage.write() as write:
+ wacm = await write.as_project_committee_member(project_name)
+ outcomes: outcome.List[types.Key] = await
wacm.keys.import_keys_file(project_name, version_name)
+
+ message = f"Uploaded {outcomes.result_count} keys,"
+ if outcomes.error_count > 0:
+ message += f" failed to upload {outcomes.error_count} keys for
{wacm.committee_name}"
+ return await session.redirect(
+ get.compose.selected,
+ success=message,
+ project_name=project_name,
+ version_name=version_name,
+ )
+
+
[email protected]("/keys/ssh/add")
+async def ssh_add(session: web.Committer) -> response.Response | str:
+ """Add a new SSH key to the user's account."""
+ return await shared.keys.ssh_add(session)
+
+
[email protected]("/keys/update-committee-keys/<committee_name>")
+async def update_committee_keys(session: web.Committer, committee_name: str)
-> response.Response:
+ """Generate and save the KEYS file for a specific committee."""
+ form = await shared.keys.UpdateCommitteeKeysForm.create_form()
+ if not await form.validate_on_submit():
+ return await session.redirect(get.keys.keys, error="Invalid request to
update KEYS file.")
+
+ async with storage.write() as write:
+ wacm = write.as_committee_member(committee_name)
+ match await wacm.keys.autogenerate_keys_file():
+ case outcome.Result():
+ await quart.flash(
+ f'Successfully regenerated the KEYS file for the
"{committee_name}" committee.', "success"
+ )
+ case outcome.Error():
+ await quart.flash(f"Error regenerating the KEYS file for the
{committee_name} committee.", "error")
+
+ return await session.redirect(get.keys.keys)
+
+
[email protected]("/keys/upload")
+async def upload(session: web.Committer) -> str:
+ """Upload a KEYS file containing multiple OpenPGP keys."""
+ return await shared.keys.upload(session)
diff --git a/atr/routes/preview.py b/atr/post/preview.py
similarity index 57%
rename from atr/routes/preview.py
rename to atr/post/preview.py
index e29e130..9e55944 100644
--- a/atr/routes/preview.py
+++ b/atr/post/preview.py
@@ -15,26 +15,18 @@
# specific language governing permissions and limitations
# under the License.
-"""preview.py"""
-
-import asfquart
import quart
import werkzeug.wrappers.response as response
+import atr.blueprints.post as post
import atr.construct as construct
import atr.forms as forms
import atr.log as log
import atr.models.sql as sql
-import atr.route as route
import atr.routes.root as root
import atr.storage as storage
-import atr.template as template
-import atr.util as util
import atr.web as web
-if asfquart.APP is ...:
- raise RuntimeError("APP is not set")
-
class AnnouncePreviewForm(forms.Typed):
"""Form for validating preview request data."""
@@ -53,12 +45,13 @@ class DeleteForm(forms.Typed):
submit = forms.submit("Delete preview")
[email protected]("/preview/announce/<project_name>/<version_name>",
methods=["POST"])
[email protected]("/preview/announce/<project_name>/<version_name>")
async def announce_preview(
- session: route.CommitterSession, project_name: str, version_name: str
+ session: web.Committer, project_name: str, version_name: str
) -> quart.wrappers.response.Response | str:
"""Generate a preview of the announcement email body."""
+ # TODO: Where does this come from? A static template?
form = await AnnouncePreviewForm.create_form(data=await quart.request.form)
if not await form.validate_on_submit():
error_message = "Invalid preview request"
@@ -84,9 +77,10 @@ async def announce_preview(
return web.TextResponse(f"Error generating preview: {e!s}", status=500)
[email protected]("/preview/delete", methods=["POST"])
-async def delete(session: route.CommitterSession) -> response.Response:
[email protected]("/preview/delete")
+async def delete(session: web.Committer) -> response.Response:
"""Delete a preview and all its associated files."""
+ # TODO: Where does this come from? A static template?
form = await DeleteForm.create_form(data=await quart.request.form)
if not await form.validate_on_submit():
@@ -109,62 +103,3 @@ async def delete(session: route.CommitterSession) ->
response.Response:
)
return await session.redirect(root.index, success="Preview deleted
successfully")
-
-
[email protected]("/preview/view/<project_name>/<version_name>")
-async def view(session: route.CommitterSession, project_name: str,
version_name: str) -> response.Response | str:
- """View all the files in the rsync upload directory for a release."""
- await session.check_access(project_name)
-
- release = await session.release(project_name, version_name,
phase=sql.ReleasePhase.RELEASE_PREVIEW)
-
- # Convert async generator to list
- # There must be a revision on a preview
- file_stats = [
- stat
- async for stat in util.content_list(
- util.get_unfinished_dir(), project_name, version_name,
release.unwrap_revision_number
- )
- ]
- # Sort the files by FileStat.path
- file_stats.sort(key=lambda fs: fs.path)
-
- return await template.render(
- # TODO: Move to somewhere appropriate
- "phase-view.html",
- file_stats=file_stats,
- release=release,
- format_datetime=util.format_datetime,
- format_file_size=util.format_file_size,
- format_permissions=util.format_permissions,
- phase="release preview",
- phase_key="preview",
- )
-
-
[email protected]("/preview/view/<project_name>/<version_name>/<path:file_path>")
-async def view_path(
- session: route.CommitterSession, project_name: str, version_name: str,
file_path: str
-) -> response.Response | str:
- """View the content of a specific file in the release preview."""
- await session.check_access(project_name)
-
- release = await session.release(project_name, version_name,
phase=sql.ReleasePhase.RELEASE_PREVIEW)
- _max_view_size = 1 * 1024 * 1024
- full_path = util.release_directory(release) / file_path
- content_listing = await util.archive_listing(full_path)
- content, is_text, is_truncated, error_message = await
util.read_file_for_viewer(full_path, _max_view_size)
- return await template.render(
- "file-selected-path.html",
- release=release,
- project_name=project_name,
- version_name=version_name,
- file_path=file_path,
- content=content,
- is_text=is_text,
- is_truncated=is_truncated,
- error_message=error_message,
- format_file_size=util.format_file_size,
- phase_key="preview",
- content_listing=content_listing,
- )
diff --git a/atr/post/projects.py b/atr/post/projects.py
new file mode 100644
index 0000000..21626af
--- /dev/null
+++ b/atr/post/projects.py
@@ -0,0 +1,64 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+import quart
+
+import atr.blueprints.post as post
+import atr.get as get
+import atr.shared as shared
+import atr.storage as storage
+import atr.util as util
+import atr.web as web
+
+if TYPE_CHECKING:
+ import werkzeug.wrappers.response as response
+
+
[email protected]("/project/add/<committee_name>")
+async def add_project(session: web.Committer, committee_name: str) ->
response.Response | str:
+ return await shared.projects.add_project(session, committee_name)
+
+
[email protected]("/project/delete")
+async def delete(session: web.Committer) -> response.Response:
+ """Delete a project created by the user."""
+ # TODO: This is not truly empty, so make a form object for this
+ await util.validate_empty_form()
+ form_data = await quart.request.form
+ project_name = form_data.get("project_name")
+ if not project_name:
+ return await session.redirect(get.projects.projects, error="Missing
project name for deletion.")
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project_name)
+ try:
+ await wacm.project.delete(project_name)
+ except storage.AccessError as e:
+ # TODO: Redirect to committees
+ return await session.redirect(get.projects.projects, error=f"Error
deleting project: {e}")
+
+ # TODO: Redirect to committees
+ return await session.redirect(get.projects.projects, success=f"Project
'{project_name}' deleted successfully.")
+
+
[email protected]("/projects/<name>")
+async def view(session: web.Committer, name: str) -> response.Response | str:
+ return await shared.projects.view(session, name)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index a3c6e18..9d61ba3 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -15,9 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-import atr.routes.keys as keys
-import atr.routes.preview as preview
-import atr.routes.projects as projects
import atr.routes.published as published
import atr.routes.ref as ref
import atr.routes.release as release
@@ -33,9 +30,6 @@ import atr.routes.user as user
import atr.routes.voting as voting
__all__ = [
- "keys",
- "preview",
- "projects",
"published",
"ref",
"release",
diff --git a/atr/shared/__init__.py b/atr/shared/__init__.py
index df2ffe0..a22b198 100644
--- a/atr/shared/__init__.py
+++ b/atr/shared/__init__.py
@@ -30,6 +30,8 @@ import atr.shared.distribution as distribution
import atr.shared.draft as draft
import atr.shared.finish as finish
import atr.shared.ignores as ignores
+import atr.shared.keys as keys
+import atr.shared.projects as projects
import atr.shared.vote as vote
import atr.storage as storage
import atr.template as template
@@ -182,5 +184,7 @@ __all__ = [
"draft",
"finish",
"ignores",
+ "keys",
+ "projects",
"vote",
]
diff --git a/atr/routes/keys.py b/atr/shared/keys.py
similarity index 73%
rename from atr/routes/keys.py
rename to atr/shared/keys.py
index 71e0565..b9d50b5 100644
--- a/atr/routes/keys.py
+++ b/atr/shared/keys.py
@@ -31,7 +31,7 @@ import wtforms
import atr.db as db
import atr.forms as forms
-import atr.get.compose as compose
+import atr.get as get
import atr.log as log
import atr.models.sql as sql
import atr.route as route
@@ -135,8 +135,7 @@ class UploadKeyFormBase(forms.Typed):
return True
[email protected]("/keys/add", methods=["GET", "POST"])
-async def add(session: route.CommitterSession) -> str:
+async def add(session: web.Committer) -> str:
"""Add a new public signing key to the user's account."""
key_info = None
@@ -191,40 +190,7 @@ async def add(session: route.CommitterSession) -> str:
)
[email protected]("/keys/delete", methods=["POST"])
-async def delete(session: route.CommitterSession) -> response.Response:
- """Delete a public signing key or SSH key from the user's account."""
- form = await DeleteKeyForm.create_form(data=await quart.request.form)
-
- if not await form.validate_on_submit():
- return await session.redirect(keys, error="Invalid request for key
deletion.")
-
- fingerprint = (await quart.request.form).get("fingerprint")
- if not fingerprint:
- return await session.redirect(keys, error="Missing key fingerprint for
deletion.")
-
- # Try to delete an SSH key first
- # Otherwise, delete an OpenPGP key
- # TODO: Unmerge this, or identify the key type
- async with storage.write() as write:
- wafc = write.as_foundation_committer()
- try:
- await wafc.ssh.delete_key(fingerprint)
- except storage.AccessError:
- pass
- else:
- return await session.redirect(keys, success="SSH key deleted
successfully")
- oc: outcome.Outcome[sql.PublicSigningKey] = await
wafc.keys.delete_key(fingerprint)
-
- match oc:
- case outcome.Result():
- return await session.redirect(keys, success="Key deleted
successfully")
- case outcome.Error(error):
- return await session.redirect(keys, error=f"Error deleting key:
{error}")
-
-
[email protected]("/keys/details/<fingerprint>", methods=["GET", "POST"])
-async def details(session: route.CommitterSession, fingerprint: str) -> str |
response.Response:
+async def details(session: web.Committer, fingerprint: str) -> str |
response.Response:
"""Display details for a specific OpenPGP key."""
fingerprint = fingerprint.lower()
user_committees = []
@@ -282,74 +248,7 @@ async def details(session: route.CommitterSession,
fingerprint: str) -> str | re
)
[email protected]("/keys/export/<committee_name>")
-async def export(session: route.CommitterSession, committee_name: str) ->
web.TextResponse:
- """Export a KEYS file for a specific committee."""
- async with storage.write() as write:
- wafc = write.as_foundation_committer()
- keys_file_text = await wafc.keys.keys_file_text(committee_name)
-
- return web.TextResponse(keys_file_text)
-
-
[email protected]("/keys/import/<project_name>/<version_name>",
methods=["POST"])
-async def import_selected_revision(
- session: route.CommitterSession, project_name: str, version_name: str
-) -> response.Response:
- await util.validate_empty_form()
-
- async with storage.write() as write:
- wacm = await write.as_project_committee_member(project_name)
- outcomes: outcome.List[types.Key] = await
wacm.keys.import_keys_file(project_name, version_name)
-
- message = f"Uploaded {outcomes.result_count} keys,"
- if outcomes.error_count > 0:
- message += f" failed to upload {outcomes.error_count} keys for
{wacm.committee_name}"
- return await session.redirect(
- compose.selected,
- success=message,
- project_name=project_name,
- version_name=version_name,
- )
-
-
[email protected]("/keys")
-async def keys(session: route.CommitterSession) -> str:
- """View all keys associated with the user's account."""
- committees_to_query = list(set(session.committees + session.projects))
-
- delete_form = await DeleteKeyForm.create_form()
- update_committee_keys_form = await UpdateCommitteeKeysForm.create_form()
-
- async with db.session() as data:
- user_keys = await
data.public_signing_key(apache_uid=session.uid.lower(), _committees=True).all()
- user_ssh_keys = await data.ssh_key(asf_uid=session.uid).all()
- user_committees_with_keys = await
data.committee(name_in=committees_to_query, _public_signing_keys=True).all()
- for key in user_keys:
- key.committees.sort(key=lambda c: c.name)
-
- status_message = quart.request.args.get("status_message")
- status_type = quart.request.args.get("status_type")
-
- return await template.render(
- "keys-review.html",
- asf_id=session.uid,
- user_keys=user_keys,
- user_ssh_keys=user_ssh_keys,
- committees=user_committees_with_keys,
- algorithms=shared.algorithms,
- status_message=status_message,
- status_type=status_type,
- now=datetime.datetime.now(datetime.UTC),
- delete_form=delete_form,
- update_committee_keys_form=update_committee_keys_form,
- email_from_key=util.email_from_uid,
- committee_is_standing=util.committee_is_standing,
- )
-
-
[email protected]("/keys/ssh/add", methods=["GET", "POST"])
-async def ssh_add(session: route.CommitterSession) -> response.Response | str:
+async def ssh_add(session: web.Committer) -> response.Response | str:
"""Add a new SSH key to the user's account."""
# TODO: Make an auth.require wrapper that gives the session automatically
# And the form if it's a POST handler? Might be hard to type
@@ -371,7 +270,7 @@ async def ssh_add(session: route.CommitterSession) ->
response.Response | str:
form.key.errors = [str(e)]
else:
success_message = f"SSH key added successfully: {fingerprint}"
- return await session.redirect(keys, success=success_message)
+ return await session.redirect(get.keys.keys,
success=success_message)
return await template.render(
"keys-ssh-add.html",
@@ -381,28 +280,7 @@ async def ssh_add(session: route.CommitterSession) ->
response.Response | str:
)
[email protected]("/keys/update-committee-keys/<committee_name>",
methods=["POST"])
-async def update_committee_keys(session: route.CommitterSession,
committee_name: str) -> response.Response:
- """Generate and save the KEYS file for a specific committee."""
- form = await UpdateCommitteeKeysForm.create_form()
- if not await form.validate_on_submit():
- return await session.redirect(keys, error="Invalid request to update
KEYS file.")
-
- async with storage.write() as write:
- wacm = write.as_committee_member(committee_name)
- match await wacm.keys.autogenerate_keys_file():
- case outcome.Result():
- await quart.flash(
- f'Successfully regenerated the KEYS file for the
"{committee_name}" committee.', "success"
- )
- case outcome.Error():
- await quart.flash(f"Error regenerating the KEYS file for the
{committee_name} committee.", "error")
-
- return await session.redirect(keys)
-
-
[email protected]("/keys/upload", methods=["GET", "POST"])
-async def upload(session: route.CommitterSession) -> str:
+async def upload(session: web.Committer) -> str:
"""Upload a KEYS file containing multiple OpenPGP keys."""
async with storage.write() as write:
participant_of_committees = await write.participant_of_committees()
@@ -505,7 +383,7 @@ async def _get_keys_text(keys_url: str, render:
Callable[[str], Awaitable[str]])
async def _key_and_is_owner(
- data: db.Session, session: route.CommitterSession, fingerprint: str
+ data: db.Session, session: web.Committer, fingerprint: str
) -> tuple[sql.PublicSigningKey, bool]:
key = await data.public_signing_key(fingerprint=fingerprint,
_committees=True).get()
if not key:
diff --git a/atr/shared/projects.py b/atr/shared/projects.py
new file mode 100644
index 0000000..18e9017
--- /dev/null
+++ b/atr/shared/projects.py
@@ -0,0 +1,538 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+import datetime
+import http.client
+import re
+from typing import TYPE_CHECKING, Any
+
+import asfquart.base as base
+import quart
+
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.forms as forms
+import atr.log as log
+import atr.models.policy as policy
+import atr.models.sql as sql
+import atr.registry as registry
+import atr.shared as shared
+import atr.storage as storage
+import atr.template as template
+import atr.user as user
+import atr.util as util
+import atr.web as web
+
+if TYPE_CHECKING:
+ import werkzeug.wrappers.response as response
+
+
+class AddForm(forms.Typed):
+ committee_name = forms.hidden()
+ display_name = forms.string("Display name")
+ label = forms.string("Label")
+ submit = forms.submit("Add project")
+
+
+class ProjectMetadataForm(forms.Typed):
+ project_name = forms.hidden()
+ category_to_add = forms.optional("New category name")
+ language_to_add = forms.optional("New language name")
+
+
+class ReleasePolicyForm(forms.Typed):
+ """
+ A Form to create or edit a ReleasePolicy.
+
+ TODO: Currently only a single mailto_address is supported.
+ see:
https://stackoverflow.com/questions/49066046/append-entry-to-fieldlist-with-flask-wtforms-using-ajax
+ """
+
+ project_name = forms.hidden()
+
+ # Compose section
+ source_artifact_paths = forms.textarea(
+ "Source artifact paths",
+ optional=True,
+ rows=5,
+ description="Paths to source artifacts to be included in the release.",
+ )
+ binary_artifact_paths = forms.textarea(
+ "Binary artifact paths",
+ optional=True,
+ rows=5,
+ description="Paths to binary artifacts to be included in the release.",
+ )
+ github_repository_name = forms.optional(
+ "GitHub repository name",
+ description="The name of the GitHub repository to use for the release,
excluding the apache/ prefix.",
+ )
+ github_compose_workflow_path = forms.textarea(
+ "GitHub compose workflow paths",
+ optional=True,
+ rows=5,
+ description="The full paths to the GitHub workflows to use for the
release,"
+ " including the .github/workflows/ prefix.",
+ )
+ strict_checking = forms.boolean(
+ "Strict checking", description="If enabled, then the release cannot be
voted upon unless all checks pass."
+ )
+
+ # Vote section
+ github_vote_workflow_path = forms.textarea(
+ "GitHub vote workflow paths",
+ optional=True,
+ rows=5,
+ description="The full paths to the GitHub workflows to use for the
release,"
+ " including the .github/workflows/ prefix.",
+ )
+ mailto_addresses = forms.string(
+ "Email",
+ validators=[forms.REQUIRED, forms.EMAIL],
+ placeholder="E.g. [email protected]",
+ description=f"The mailing list where vote emails are sent. This is
usually"
+ "your dev list. ATR will currently only send test announcement emails
to"
+ f"{util.USER_TESTS_ADDRESS}.",
+ )
+ manual_vote = forms.boolean(
+ "Manual voting process",
+ description="If this is set then the vote will be completely manual
and following policy is ignored.",
+ )
+ default_min_hours_value_at_render = forms.hidden()
+ min_hours = forms.integer(
+ "Minimum voting period",
+ validators=[util.validate_vote_duration],
+ default=72,
+ description="The minimum time to run the vote, in hours. Must be 0 or
between 72 and 144 inclusive."
+ " If 0, then wait until 3 +1 votes and more +1 than -1.",
+ )
+ pause_for_rm = forms.boolean(
+ "Pause for RM", description="If enabled, RM can confirm manually if
the vote has passed."
+ )
+ release_checklist = forms.textarea(
+ "Release checklist",
+ optional=True,
+ rows=10,
+ description="Markdown text describing how to test release candidates.",
+ )
+ default_start_vote_template_hash = forms.hidden()
+ start_vote_template = forms.textarea(
+ "Start vote template",
+ optional=True,
+ rows=10,
+ description="Email template for messages to start a vote on a
release.",
+ )
+
+ # Finish section
+ default_announce_release_template_hash = forms.hidden()
+ announce_release_template = forms.textarea(
+ "Announce release template",
+ optional=True,
+ rows=10,
+ description="Email template for messages to announce a finished
release.",
+ )
+ github_finish_workflow_path = forms.textarea(
+ "GitHub finish workflow paths",
+ optional=True,
+ rows=5,
+ description="The full paths to the GitHub workflows to use for the
release,"
+ " including the .github/workflows/ prefix.",
+ )
+ preserve_download_files = forms.boolean(
+ "Preserve download files",
+ description="If enabled, existing download files will not be
overwritten.",
+ )
+
+ submit_policy = forms.submit("Save")
+
+ async def validate(self, extra_validators: dict[str, Any] | None = None)
-> bool: # noqa: C901
+ await super().validate(extra_validators=extra_validators)
+
+ if self.manual_vote.data:
+ for field_name in (
+ "mailto_addresses",
+ "min_hours",
+ "pause_for_rm",
+ "release_checklist",
+ "start_vote_template",
+ ):
+ field = getattr(self, field_name, None)
+ if field is not None:
+ forms.clear_errors(field)
+ self.errors.pop(field_name, None)
+
+ if self.manual_vote.data and self.strict_checking.data:
+ msg = "Manual voting process and strict checking cannot be enabled
simultaneously."
+ forms.error(self.manual_vote, msg)
+ forms.error(self.strict_checking, msg)
+
+ github_repository_name = (self.github_repository_name.data or
"").strip()
+ compose_raw = self.github_compose_workflow_path.data or ""
+ vote_raw = self.github_vote_workflow_path.data or ""
+ finish_raw = self.github_finish_workflow_path.data or ""
+ compose = [p.strip() for p in compose_raw.split("\n") if p.strip()]
+ vote = [p.strip() for p in vote_raw.split("\n") if p.strip()]
+ finish = [p.strip() for p in finish_raw.split("\n") if p.strip()]
+
+ any_path = bool(compose or vote or finish)
+ if any_path and (not github_repository_name):
+ forms.error(
+ self.github_repository_name,
+ "GitHub repository name is required when any workflow path is
set.",
+ )
+
+ if github_repository_name and ("/" in github_repository_name):
+ forms.error(self.github_repository_name, "GitHub repository name
must not contain a slash.")
+
+ if compose:
+ for p in compose:
+ if not p.startswith(".github/workflows/"):
+ forms.error(
+ self.github_compose_workflow_path,
+ "GitHub workflow paths must start with
'.github/workflows/'.",
+ )
+ break
+ if vote:
+ for p in vote:
+ if not p.startswith(".github/workflows/"):
+ forms.error(
+ self.github_vote_workflow_path,
+ "GitHub workflow paths must start with
'.github/workflows/'.",
+ )
+ break
+ if finish:
+ for p in finish:
+ if not p.startswith(".github/workflows/"):
+ forms.error(
+ self.github_finish_workflow_path,
+ "GitHub workflow paths must start with
'.github/workflows/'.",
+ )
+ break
+
+ return not self.errors
+
+
+async def add_project(session: web.Committer, committee_name: str) ->
response.Response | 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 view(session: web.Committer, name: str) -> response.Response | str:
+ policy_form = None
+ metadata_form = None
+ can_edit = False
+
+ async with db.session() as data:
+ project = await data.project(
+ name=name, _committee=True, _committee_public_signing_keys=True,
_release_policy=True
+ ).demand(http.client.HTTPException(404))
+
+ is_committee_member = project.committee and
(user.is_committee_member(project.committee, session.uid))
+ is_privileged = user.is_admin(session.uid)
+ can_edit = is_committee_member or is_privileged
+
+ if can_edit and (quart.request.method == "POST"):
+ form_data = await quart.request.form
+ if "submit_metadata" in form_data:
+ edited_metadata, metadata_form = await _metadata_edit(session,
project, form_data)
+ if edited_metadata is True:
+ return quart.redirect(util.as_url(view, name=project.name))
+ elif "submit_policy" in form_data:
+ policy_form = await ReleasePolicyForm.create_form(data=form_data)
+ if await policy_form.validate_on_submit():
+ policy_data =
policy.ReleasePolicyData.model_validate(policy_form.data)
+ async with storage.write(session) as write:
+ wacm = await
write.as_project_committee_member(project.name)
+ try:
+ await wacm.policy.edit(project, policy_data)
+ except storage.AccessError as e:
+ return await session.redirect(view, name=project.name,
error=f"Error editing policy: {e}")
+ return quart.redirect(util.as_url(view, name=project.name))
+ else:
+ log.info(f"policy_form.errors: {policy_form.errors}")
+ else:
+ log.info(f"Unknown form data: {form_data}")
+
+ if metadata_form is None:
+ metadata_form = await
ProjectMetadataForm.create_form(data={"project_name": project.name})
+ if policy_form is None:
+ policy_form = await _policy_form_create(project)
+ candidate_drafts = await interaction.candidate_drafts(project)
+ candidates = await interaction.candidates(project)
+ previews = await interaction.previews(project)
+ full_releases = await interaction.full_releases(project)
+
+ return await template.render(
+ "project-view.html",
+ project=project,
+ algorithms=shared.algorithms,
+ candidate_drafts=candidate_drafts,
+ candidates=candidates,
+ previews=previews,
+ full_releases=full_releases,
+ number_of_release_files=util.number_of_release_files,
+ now=datetime.datetime.now(datetime.UTC),
+ empty_form=await forms.Empty.create_form(),
+ policy_form=policy_form,
+ can_edit=can_edit,
+ metadata_form=metadata_form,
+ forbidden_categories=registry.FORBIDDEN_PROJECT_CATEGORIES,
+ )
+
+
+async def _metadata_category_add(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project,
category_to_add: str
+) -> bool:
+ modified = False
+ try:
+ modified = await wacm.project.category_add(project,
category_to_add.strip())
+ except storage.AccessError as e:
+ await quart.flash(f"Error adding category: {e}", "error")
+ if modified:
+ await quart.flash(f"Category '{category_to_add}' added.", "success")
+ else:
+ await quart.flash(f"Category '{category_to_add}' already exists.",
"error")
+ return modified
+
+
+async def _metadata_category_remove(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
+) -> bool:
+ modified = False
+ try:
+ modified = await wacm.project.category_remove(project, action_value)
+ except storage.AccessError as e:
+ await quart.flash(f"Error removing category: {e}", "error")
+ if modified:
+ await quart.flash(f"Category '{action_value}' removed.", "success")
+ else:
+ await quart.flash(f"Category '{action_value}' does not exist.",
"error")
+ return modified
+
+
+async def _metadata_edit(
+ session: web.Committer, project: sql.Project, form_data: dict[str, str]
+) -> tuple[bool, ProjectMetadataForm]:
+ metadata_form = await ProjectMetadataForm.create_form(data=form_data)
+
+ validated = await metadata_form.validate_on_submit()
+ if not validated:
+ return False, metadata_form
+
+ form_data = await quart.request.form
+ action_full = form_data.get("action", "")
+ action_type = ""
+ action_value = ""
+ if ":" in action_full:
+ action_type, action_value = action_full.split(":", 1)
+ else:
+ action_type = action_full
+
+ # TODO: Add error handling
+ modified = False
+ category_to_add = metadata_form.category_to_add.data
+ language_to_add = metadata_form.language_to_add.data
+
+ async with storage.write(session) as write:
+ wacm = await write.as_project_committee_member(project.name)
+
+ if (action_type == "add_category") and category_to_add:
+ modified = await _metadata_category_add(wacm, project,
category_to_add)
+ elif (action_type == "remove_category") and action_value:
+ modified = await _metadata_category_remove(wacm, project,
action_value)
+ elif (action_type == "add_language") and language_to_add:
+ modified = await _metadata_language_add(wacm, project,
language_to_add)
+ elif (action_type == "remove_language") and action_value:
+ modified = await _metadata_language_remove(wacm, project,
action_value)
+
+ return modified, metadata_form
+
+
+async def _metadata_language_add(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project,
language_to_add: str
+) -> bool:
+ modified = False
+ try:
+ modified = await wacm.project.language_add(project, language_to_add)
+ except storage.AccessError as e:
+ await quart.flash(f"Error adding language: {e}", "error")
+ if modified:
+ await quart.flash(f"Language '{language_to_add}' added.", "success")
+ else:
+ await quart.flash(f"Language '{language_to_add}' already exists.",
"error")
+ return modified
+
+
+async def _metadata_language_remove(
+ wacm: storage.WriteAsCommitteeMember, project: sql.Project, action_value:
str
+) -> bool:
+ modified = False
+ try:
+ modified = await wacm.project.language_remove(project, action_value)
+ except storage.AccessError as e:
+ await quart.flash(f"Error removing language: {e}", "error")
+ if modified:
+ await quart.flash(f"Language '{action_value}' removed.", "success")
+ else:
+ await quart.flash(f"Language '{action_value}' does not exist.",
"error")
+ return modified
+
+
+async def _policy_form_create(project: sql.Project) -> ReleasePolicyForm:
+ # TODO: Use form order for all of these fields
+ policy_form = await ReleasePolicyForm.create_form()
+ policy_form.project_name.data = project.name
+ if project.policy_mailto_addresses:
+ policy_form.mailto_addresses.data = project.policy_mailto_addresses[0]
+ else:
+ policy_form.mailto_addresses.data = f"dev@{project.name}.apache.org"
+ policy_form.min_hours.data = project.policy_min_hours
+ policy_form.manual_vote.data = project.policy_manual_vote
+ policy_form.release_checklist.data = project.policy_release_checklist
+ policy_form.start_vote_template.data = project.policy_start_vote_template
+ policy_form.announce_release_template.data =
project.policy_announce_release_template
+ policy_form.binary_artifact_paths.data =
"\n".join(project.policy_binary_artifact_paths)
+ policy_form.source_artifact_paths.data =
"\n".join(project.policy_source_artifact_paths)
+ policy_form.pause_for_rm.data = project.policy_pause_for_rm
+ policy_form.strict_checking.data = project.policy_strict_checking
+ policy_form.github_repository_name.data =
project.policy_github_repository_name
+ policy_form.github_compose_workflow_path.data =
"\n".join(project.policy_github_compose_workflow_path)
+ policy_form.github_vote_workflow_path.data =
"\n".join(project.policy_github_vote_workflow_path)
+ policy_form.github_finish_workflow_path.data =
"\n".join(project.policy_github_finish_workflow_path)
+ policy_form.preserve_download_files.data =
project.policy_preserve_download_files
+
+ # Set the hashes and value of the current defaults
+ policy_form.default_start_vote_template_hash.data = util.compute_sha3_256(
+ project.policy_start_vote_default.encode()
+ )
+ policy_form.default_announce_release_template_hash.data =
util.compute_sha3_256(
+ project.policy_announce_release_default.encode()
+ )
+ policy_form.default_min_hours_value_at_render.data =
str(project.policy_default_min_hours)
+ return policy_form
+
+
+async def _project_add(form: AddForm, session: web.Committer) ->
response.Response:
+ form_values = await _project_add_validate(form)
+ if form_values is None:
+ return quart.redirect(util.as_url(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(add_project,
committee_name=committee_name))
+
+ return quart.redirect(util.as_url(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/announce-selected.html
b/atr/templates/announce-selected.html
index 0627bb7..4ec4cfa 100644
--- a/atr/templates/announce-selected.html
+++ b/atr/templates/announce-selected.html
@@ -52,7 +52,7 @@
</div>
<!--
<div>
- <a title="Show files for {{ release.name }}" href="{{
as_url(routes.preview.view, project_name=release.project.name,
version_name=release.version) }}" class="btn btn-sm btn-secondary">
+ <a title="Show files for {{ release.name }}" href="{{
as_url(get.preview.view, project_name=release.project.name,
version_name=release.version) }}" class="btn btn-sm btn-secondary">
<i class="bi bi-archive"></i>
Show files
</a>
@@ -182,7 +182,7 @@
return;
}
- const previewUrl = "{{ as_url(routes.preview.announce_preview,
project_name=release.project.name, version_name=release.version) }}";
+ const previewUrl = "{{ as_url(post.preview.announce_preview,
project_name=release.project.name, version_name=release.version) }}";
const csrfTokenInput =
announceForm.querySelector('input[name="csrf_token"]');
if (!previewUrl || !csrfTokenInput) {
diff --git a/atr/templates/check-selected-path-table.html
b/atr/templates/check-selected-path-table.html
index b2375b7..1134bf9 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -58,7 +58,7 @@
<div class="d-flex justify-content-end align-items-center gap-2">
{% if path|string == "KEYS" %}
<form method="post"
- action="{{ as_url(routes.keys.import_selected_revision,
project_name=project_name, version_name=version_name) }}"
+ action="{{ as_url(post.keys.import_selected_revision,
project_name=project_name, version_name=version_name) }}"
class="d-inline mb-0">
{{ empty_form.hidden_tag() }}
diff --git a/atr/templates/check-selected-release-info.html
b/atr/templates/check-selected-release-info.html
index 643d441..70804e3 100644
--- a/atr/templates/check-selected-release-info.html
+++ b/atr/templates/check-selected-release-info.html
@@ -7,7 +7,7 @@
<div class="col-md-6">
<p>
<strong>Project:</strong>
- <a href="{{ as_url(routes.projects.view, name=release.project.name)
}}">{{ release.project.display_name }}</a>
+ <a href="{{ as_url(get.projects.view, name=release.project.name)
}}">{{ release.project.display_name }}</a>
</p>
<p>
<strong>Label:</strong> {{ release.name }}
diff --git a/atr/templates/committee-directory.html
b/atr/templates/committee-directory.html
index 73d6904..f5d2f84 100644
--- a/atr/templates/committee-directory.html
+++ b/atr/templates/committee-directory.html
@@ -122,11 +122,11 @@
{% if committee.projects %}
{% for project in committee.projects|sort(attribute="name") %}
<div class="card mb-3 shadow-sm page-project-subcard {% if
loop.index > max_initial_projects %}page-project-extra d-none{% endif %} {% if
project.status.value.lower() != "active" %}page-project-inactive{% endif %}"
- data-project-url="{{ as_url(routes.projects.view,
name=project.name) }}">
+ data-project-url="{{ as_url(get.projects.view,
name=project.name) }}">
<div class="card-body p-3 d-flex flex-column h-100">
<div class="d-flex justify-content-between
align-items-start">
<p class="mb-1 me-2 fs-6">
- <a href="{{ as_url(routes.projects.view,
name=project.name) }}"
+ <a href="{{ as_url(get.projects.view,
name=project.name) }}"
class="text-decoration-none stretched-link">{{
project.display_name }}</a>
</p>
<div>
@@ -159,7 +159,7 @@
{% endif %}
</div>
{% if current_user and is_part and (not
committee_is_standing(committee.name)) %}
- <a href="{{ as_url(routes.projects.add_project,
committee_name=committee.name) }}"
+ <a href="{{ as_url(get.projects.add_project,
committee_name=committee.name) }}"
title="Create a project for {{ committee.display_name }}"
class="text-decoration-none d-block mt-4 mb-3">
<div class="card h-100 shadow-sm atr-cursor-pointer
page-project-subcard">
diff --git a/atr/templates/committee-view.html
b/atr/templates/committee-view.html
index ee08cd4..c651777 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -33,7 +33,7 @@
<ul>
{% for project in projects %}
<li>
- <a href="{{ as_url(routes.projects.view, name=project.name) }}">{{
project.display_name }}</a>
+ <a href="{{ as_url(get.projects.view, name=project.name) }}">{{
project.display_name }}</a>
</li>
{% endfor %}
</ul>
@@ -48,8 +48,7 @@
</div>
<div class="card-body">
<div class="mb-4">
- <a href="{{ as_url(routes.keys.upload) }}"
- class="btn btn-outline-primary">Upload a KEYS file</a>
+ <a href="{{ as_url(get.keys.upload) }}" class="btn
btn-outline-primary">Upload a KEYS file</a>
</div>
{% if committee.public_signing_keys %}
<div class="table-responsive mb-2">
@@ -65,7 +64,7 @@
{% for key in committee.public_signing_keys %}
<tr>
<td class="text-break font-monospace px-2">
- <a href="{{ as_url(routes.keys.details,
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+ <a href="{{ as_url(get.keys.details,
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
</td>
<td class="text-break px-2">{{
email_from_key(key.primary_declared_uid) or 'Not specified' }}</td>
<td class="text-break px-2">{{ key.apache_uid or "-"
}}</td>
@@ -78,7 +77,7 @@
The <code>KEYS</code> file is automatically generated when you add
or remove a key, but you can also use the form below to manually regenerate it.
</p>
<form method="post"
- action="{{ as_url(routes.keys.update_committee_keys,
committee_name=committee.name) }}"
+ action="{{ as_url(post.keys.update_committee_keys,
committee_name=committee.name) }}"
class="mb-4 d-inline-block">
{{ update_committee_keys_form.hidden_tag() }}
diff --git a/atr/templates/file-selected-path.html
b/atr/templates/file-selected-path.html
index d3cee26..38d56ea 100644
--- a/atr/templates/file-selected-path.html
+++ b/atr/templates/file-selected-path.html
@@ -15,7 +15,7 @@
{% elif phase_key == "candidate" %}
{% set back_url = as_url(get.candidate.view,
project_name=release.project.name, version_name=release.version) %}
{% elif phase_key == "preview" %}
- {% set back_url = as_url(routes.preview.view,
project_name=release.project.name, version_name=release.version) %}
+ {% set back_url = as_url(get.preview.view,
project_name=release.project.name, version_name=release.version) %}
{% elif phase_key == "release" %}
{% set back_url = as_url(routes.release.view,
project_name=release.project.name, version_name=release.version) %}
{% endif %}
diff --git a/atr/templates/finish-selected.html
b/atr/templates/finish-selected.html
index 3722886..ffe4e92 100644
--- a/atr/templates/finish-selected.html
+++ b/atr/templates/finish-selected.html
@@ -75,7 +75,7 @@
Download all files
</a>
<a title="Show files for {{ release.name }}"
- href="{{ as_url(routes.preview.view,
project_name=release.project.name, version_name=release.version) }}"
+ href="{{ as_url(get.preview.view,
project_name=release.project.name, version_name=release.version) }}"
class="btn btn-secondary me-2">
<i class="bi bi-archive"></i>
Show files
diff --git a/atr/templates/includes/sidebar.html
b/atr/templates/includes/sidebar.html
index 13a95e6..210465c 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -49,7 +49,7 @@
<!--
<li>
<i class="bi bi-collection"></i>
- <a href="{{ as_url(routes.projects.projects) }}">Browse projects</a>
+ <a href="{{ as_url(get.projects.projects) }}">Browse projects</a>
</li>
-->
{% endif %}
@@ -65,7 +65,7 @@
{% if unfinished_releases %}
{% for project_short_display_name, project_name, releases in
unfinished_releases %}
<h3>
- <a href="{{ as_url(routes.projects.view, name=project_name) }}"
+ <a href="{{ as_url(get.projects.view, name=project_name) }}"
class="text-decoration-none text-reset">{{
project_short_display_name }}</a>
</h3>
<ul>
@@ -105,7 +105,7 @@
<ul>
<li>
<i class="bi bi-key"></i>
- <a href="{{ as_url(routes.keys.keys) }}">Public keys</a>
+ <a href="{{ as_url(get.keys.keys) }}">Public keys</a>
</li>
<li>
<i class="bi bi-key"></i>
diff --git a/atr/templates/index-committer.html
b/atr/templates/index-committer.html
index 4786a2f..7bcbb94 100644
--- a/atr/templates/index-committer.html
+++ b/atr/templates/index-committer.html
@@ -87,10 +87,10 @@
</h2>
<p class="mb-3">
- <a href="{{ as_url(routes.projects.view, name=project.name) }}"
+ <a href="{{ as_url(get.projects.view, name=project.name) }}"
class="text-decoration-none me-2">About this project</a>
<span class="text-muted me-2">/</span>
- <a href="{{ as_url(routes.projects.add_project,
committee_name=project.committee.name) }}"
+ <a href="{{ as_url(get.projects.add_project,
committee_name=project.committee.name) }}"
class="text-decoration-none me-2">Create a sibling project</a>
{% if completed_releases %}
<span class="text-muted me-2">/</span>
diff --git a/atr/templates/keys-add.html b/atr/templates/keys-add.html
index fc5ebb0..908ed5c 100644
--- a/atr/templates/keys-add.html
+++ b/atr/templates/keys-add.html
@@ -10,7 +10,7 @@
{% block content %}
<p>
- <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
+ <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
</p>
<div class="my-4">
@@ -21,7 +21,7 @@
<form method="post"
class="atr-canary py-4 px-5"
- action="{{ as_url(routes.keys.add) }}"
+ action="{{ as_url(post.keys.add) }}"
novalidate>
{{ form.hidden_tag() }}
@@ -61,7 +61,7 @@
<div class="mt-4 col-md-9 offset-md-3 px-1">
{{ form.submit(class_='btn btn-primary') }}
- <a href="{{ as_url(routes.keys.keys) }}"
+ <a href="{{ as_url(get.keys.keys) }}"
class="btn btn-link text-secondary">Cancel</a>
</div>
</form>
diff --git a/atr/templates/keys-details.html b/atr/templates/keys-details.html
index 08bce30..b55833d 100644
--- a/atr/templates/keys-details.html
+++ b/atr/templates/keys-details.html
@@ -10,7 +10,7 @@
{% block content %}
<p>
- <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
+ <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
</p>
<h1>OpenPGP key details</h1>
diff --git a/atr/templates/keys-review.html b/atr/templates/keys-review.html
index 68e2bbe..ff5d441 100644
--- a/atr/templates/keys-review.html
+++ b/atr/templates/keys-review.html
@@ -20,9 +20,8 @@
<p>Review your public keys used for signing release artifacts.</p>
<div class="d-flex gap-3 mb-4">
- <a href="{{ as_url(routes.keys.add) }}" class="btn
btn-outline-primary">Add your OpenPGP key</a>
- <a href="{{ as_url(routes.keys.ssh_add) }}"
- class="btn btn-outline-primary">Add your SSH key</a>
+ <a href="{{ as_url(get.keys.add) }}" class="btn btn-outline-primary">Add
your OpenPGP key</a>
+ <a href="{{ as_url(get.keys.ssh_add) }}" class="btn
btn-outline-primary">Add your SSH key</a>
</div>
<h3>Your OpenPGP keys</h3>
@@ -41,7 +40,7 @@
{% for key in user_keys %}
<tr class="page-user-openpgp-key">
<td class="text-break px-2 align-middle">
- <a href="{{ as_url(routes.keys.details,
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+ <a href="{{ as_url(get.keys.details,
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
</td>
<td class="text-break px-2 align-middle">
{% if key.committees %}
@@ -52,7 +51,7 @@
</td>
<td class="px-2">
<form method="post"
- action="{{ as_url(routes.keys.delete) }}"
+ action="{{ as_url(post.keys.delete) }}"
class="m-0"
onsubmit="return confirm('Are you sure you want to
delete this OpenPGP key?');">
{{ delete_form.hidden_tag() }}
@@ -96,7 +95,7 @@
</details>
<form method="post"
- action="{{ as_url(routes.keys.delete) }}"
+ action="{{ as_url(post.keys.delete) }}"
class="mt-3"
onsubmit="return confirm('Are you sure you want to delete
this SSH key?');">
{{ delete_form.hidden_tag() }}
@@ -116,8 +115,7 @@
<h2 id="your-committee-keys">Your committee's keys</h2>
<div class="mb-4">
- <a href="{{ as_url(routes.keys.upload) }}"
- class="btn btn-outline-primary">Upload a KEYS file</a>
+ <a href="{{ as_url(get.keys.upload) }}" class="btn
btn-outline-primary">Upload a KEYS file</a>
</div>
{% for committee in committees %}
{% if not committee_is_standing(committee.name) %}
@@ -136,7 +134,7 @@
{% for key in committee.public_signing_keys %}
<tr>
<td class="text-break font-monospace px-2">
- <a href="{{ as_url(routes.keys.details,
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
+ <a href="{{ as_url(get.keys.details,
fingerprint=key.fingerprint) }}">{{ key.fingerprint[-16:]|upper }}</a>
</td>
<td class="text-break px-2">{{
email_from_key(key.primary_declared_uid) or 'Not specified' }}</td>
<td class="text-break px-2">{{ key.apache_uid or "-" }}</td>
@@ -149,7 +147,7 @@
The <code>KEYS</code> file is automatically generated when you add
or remove a key, but you can also use the form below to manually regenerate it.
</p>
<form method="post"
- action="{{ as_url(routes.keys.update_committee_keys,
committee_name=committee.name) }}"
+ action="{{ as_url(post.keys.update_committee_keys,
committee_name=committee.name) }}"
class="mb-4 d-inline-block">
{{ update_committee_keys_form.hidden_tag() }}
diff --git a/atr/templates/keys-ssh-add.html b/atr/templates/keys-ssh-add.html
index eb9d24e..c6c2e56 100644
--- a/atr/templates/keys-ssh-add.html
+++ b/atr/templates/keys-ssh-add.html
@@ -10,7 +10,7 @@
{% block content %}
<p>
- <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
+ <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
</p>
<h1>Add your SSH key</h1>
diff --git a/atr/templates/keys-upload.html b/atr/templates/keys-upload.html
index 6dd8d7f..36790bc 100644
--- a/atr/templates/keys-upload.html
+++ b/atr/templates/keys-upload.html
@@ -78,7 +78,7 @@
{% block content %}
<p>
- <a href="{{ as_url(routes.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
+ <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
</p>
<h1>Upload a KEYS file</h1>
@@ -241,7 +241,7 @@
<div class="mt-4 col-md-9 offset-md-2">
{{ form.submit(class_="btn btn-primary") }}
- <a href="{{ as_url(routes.keys.keys) }}"
+ <a href="{{ as_url(get.keys.keys) }}"
class="btn btn-link text-secondary">Cancel</a>
</div>
</form>
diff --git a/atr/templates/phase-view.html b/atr/templates/phase-view.html
index 486bd9c..375e785 100644
--- a/atr/templates/phase-view.html
+++ b/atr/templates/phase-view.html
@@ -103,7 +103,7 @@
{% elif phase_key == "candidate" %}
{% set file_url = as_url(get.candidate.view_path,
project_name=release.project.name, version_name=release.version,
file_path=stat.path) %}
{% elif phase_key == "preview" %}
- {% set file_url = as_url(routes.preview.view_path,
project_name=release.project.name, version_name=release.version,
file_path=stat.path) %}
+ {% set file_url = as_url(get.preview.view_path,
project_name=release.project.name, version_name=release.version,
file_path=stat.path) %}
{% elif phase_key == "release" %}
{% set file_url = as_url(routes.release.view_path,
project_name=release.project.name, version_name=release.version,
file_path=stat.path) %}
{% else %}
diff --git a/atr/templates/project-view.html b/atr/templates/project-view.html
index 12455c8..633153f 100644
--- a/atr/templates/project-view.html
+++ b/atr/templates/project-view.html
@@ -80,7 +80,7 @@
<div class="card-body">
{% if can_edit and policy_form %}
<form method="post"
- action="{{ as_url(routes.projects.view, name=project.name) }}"
+ action="{{ as_url(post.projects.view, name=project.name) }}"
class="atr-canary py-4 px-5"
novalidate>
{{ policy_form.hidden_tag() if policy_form.hidden_tag }}
@@ -330,7 +330,7 @@
<div class="card-body">
{{ forms.errors_summary(metadata_form) }}
<form method="post"
- action="{{ as_url(routes.projects.view, name=project.name) }}"
+ action="{{ as_url(post.projects.view, name=project.name) }}"
class="mb-3">
{{ metadata_form.hidden_tag() if metadata_form.hidden_tag }}
{{ metadata_form.project_name() }}
@@ -373,7 +373,7 @@
<div class="card-body">
{{ forms.errors_summary(metadata_form) }}
<form method="post"
- action="{{ as_url(routes.projects.view, name=project.name) }}"
+ action="{{ as_url(post.projects.view, name=project.name) }}"
class="mb-3">
{{ metadata_form.hidden_tag() if metadata_form.hidden_tag }}
{{ metadata_form.project_name() }}
@@ -453,7 +453,7 @@
<h2>Preview releases</h2>
<div class="d-flex flex-wrap gap-2 mb-4">
{% for preview in previews %}
- <a href="{{ as_url(routes.preview.view, project_name=project.name,
version_name=preview.version) }}"
+ <a href="{{ as_url(get.preview.view, project_name=project.name,
version_name=preview.version) }}"
class="btn btn-sm btn-outline-warning py-2 px-3"
title="View preview {{ project.name }} {{ preview.version }}">
{{ project.name }} {{ preview.version }}
@@ -494,7 +494,7 @@
<h2>Actions</h2>
<div class="my-3">
<form method="post"
- action="{{ as_url(routes.projects.delete) }}"
+ action="{{ as_url(post.projects.delete) }}"
class="d-inline-block m-0"
onsubmit="return confirm('Are you sure you want to delete the
project \'{{ project.display_name }}\'? This cannot be undone.');">
{{ empty_form.hidden_tag() }}
@@ -510,7 +510,7 @@
{% endif %}
{% if (is_committee_member or is_admin) %}
<p>
- <a href="{{ as_url(routes.projects.add_project,
committee_name=project.committee.name) }}"
+ <a href="{{ as_url(get.projects.add_project,
committee_name=project.committee.name) }}"
class="btn btn-sm btn-outline-primary">Create a sibling project</a>
</p>
{% endif %}
diff --git a/atr/templates/projects.html b/atr/templates/projects.html
index 624efde..b59156c 100644
--- a/atr/templates/projects.html
+++ b/atr/templates/projects.html
@@ -41,7 +41,7 @@
{% endif %}
<div class="col">
<div class="card h-100 shadow-sm atr-cursor-pointer page-project-card
{{ '' if project.status.value.lower() == 'active' else 'bg-body-secondary' }}"
- data-project-url="{{ as_url(routes.projects.view,
name=project.name) }}"
+ data-project-url="{{ as_url(get.projects.view, name=project.name)
}}"
data-is-participant="{{ 'true' if is_part else 'false' }}">
<div class="card-body">
<div class="row g-1">
@@ -80,7 +80,7 @@
{% if project.created_by == current_user.uid %}
<div class="mt-3">
<form method="post"
- action="{{ as_url(routes.projects.delete) }}"
+ action="{{ as_url(post.projects.delete) }}"
class="d-inline-block m-0"
onsubmit="return confirm('Are you sure you want to
delete the project \'{{ project.display_name }}\'? This cannot be undone.');">
{{ empty_form.hidden_tag() }}
diff --git a/atr/templates/release-select.html
b/atr/templates/release-select.html
index f60b710..111f24f 100644
--- a/atr/templates/release-select.html
+++ b/atr/templates/release-select.html
@@ -6,7 +6,7 @@
{% block content %}
<p class="atr-breadcrumbs">
- <a href="{{ as_url(routes.projects.select) }}"
class="atr-back-link">Select a project</a>
+ <a href="{{ as_url(get.projects.select) }}" class="atr-back-link">Select a
project</a>
<span>→</span> Select an {{ project.display_name }} release
</p>
diff --git a/atr/templates/upload-selected.html
b/atr/templates/upload-selected.html
index cc6cfd2..ed9cc83 100644
--- a/atr/templates/upload-selected.html
+++ b/atr/templates/upload-selected.html
@@ -120,7 +120,7 @@
{% if key_count == 0 %}
<div class="alert alert-warning">
<p class="mb-0">
- We have no SSH keys on file for you, so you cannot yet use this
command. Please <a href="{{ as_url(routes.keys.ssh_add) }}">add your SSH
key</a>.
+ We have no SSH keys on file for you, so you cannot yet use this
command. Please <a href="{{ as_url(get.keys.ssh_add) }}">add your SSH key</a>.
</p>
</div>
{% endif %}
diff --git a/atr/templates/user-ssh-keys.html b/atr/templates/user-ssh-keys.html
index 69a1bb5..95662d9 100644
--- a/atr/templates/user-ssh-keys.html
+++ b/atr/templates/user-ssh-keys.html
@@ -10,8 +10,8 @@
{% set key_parts = key.key.split(' ', 2) %}
{% set key_comment = key_parts[2] if key_parts|length > 2 else 'key' %}
<p>
- We have the SSH key <a href="{{ as_url(routes.keys.keys,
_anchor='ssh-key-' + key.fingerprint) }}"
- title="{{ key.fingerprint }}"><code>{{- key_comment | trim -}}</code></a>
on file for you. You can also <a href="{{ as_url(routes.keys.ssh_add) }}">add
another SSH key</a>.
+ We have the SSH key <a href="{{ as_url(get.keys.keys, _anchor='ssh-key-' +
key.fingerprint) }}"
+ title="{{ key.fingerprint }}"><code>{{- key_comment | trim -}}</code></a>
on file for you. You can also <a href="{{ as_url(get.keys.ssh_add) }}">add
another SSH key</a>.
</p>
{% elif key_count > 1 %}
<p>We have the following SSH keys on file for you:</p>
@@ -20,12 +20,12 @@
{% set key_parts = key.key.split(' ', 2) %}
{% set key_comment = key_parts[2] if key_parts|length > 2 else 'key' %}
<li>
- <a href="{{ as_url(routes.keys.keys, _anchor='ssh-key-' +
key.fingerprint) }}"
+ <a href="{{ as_url(get.keys.keys, _anchor='ssh-key-' +
key.fingerprint) }}"
title="{{ key.fingerprint }}"><code>{{- key_comment | trim
-}}</code></a>
</li>
{% endfor %}
</ul>
<p>
- You can also <a href="{{ as_url(routes.keys.ssh_add) }}">add another SSH
key</a>.
+ You can also <a href="{{ as_url(get.keys.ssh_add) }}">add another SSH
key</a>.
</p>
{% endif %}
diff --git a/atr/templates/voting-selected-revision.html
b/atr/templates/voting-selected-revision.html
index 76e59f4..d7436bd 100644
--- a/atr/templates/voting-selected-revision.html
+++ b/atr/templates/voting-selected-revision.html
@@ -38,7 +38,7 @@
<i class="bi bi-exclamation-triangle-fill"></i>
<strong>Warning:</strong>
The KEYS file is missing.
- Please autogenerate one on the <a href="{{ as_url(routes.keys.keys)
}}#committee-{{ release.committee.name|slugify }}">KEYS page</a>.
+ Please autogenerate one on the <a href="{{ as_url(get.keys.keys)
}}#committee-{{ release.committee.name|slugify }}">KEYS page</a>.
</div>
{% endif %}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]