You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2025/04/17 15:43:49 UTC
(tooling-trusted-release) branch main updated: Add new project and release in progress selection pages
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new 1370c44 Add new project and release in progress selection pages
1370c44 is described below
commit 1370c449f24ff8ae662d38e23cf389b960def813
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Apr 17 16:42:53 2025 +0100
Add new project and release in progress selection pages
---
atr/db/models.py | 8 +++
atr/routes/__init__.py | 17 +++++--
atr/routes/draft.py | 12 +++++
atr/routes/projects.py | 38 +++++++++++++-
atr/routes/root.py | 18 ++++++-
atr/static/css/atr.css | 8 +++
atr/templates/index.html | 79 ++---------------------------
atr/templates/project-add.html | 10 ++--
atr/templates/project-choose.html | 47 +++++++++++++++++
atr/templates/project-progress.html | 72 ++++++++++++++++++++++++++
atr/templates/{index.html => tutorial.html} | 0
playwright/test.py | 12 ++---
12 files changed, 229 insertions(+), 92 deletions(-)
diff --git a/atr/db/models.py b/atr/db/models.py
index 36f7e08..256ce2a 100644
--- a/atr/db/models.py
+++ b/atr/db/models.py
@@ -228,6 +228,14 @@ class Project(sqlmodel.SQLModel, table=True):
after_announcement = await self.releases_by_phase(ReleasePhase.RELEASE_AFTER_ANNOUNCEMENT)
return before_announcement + after_announcement
+ @property
+ async def releases_in_progress(self) -> list["Release"]:
+ """Get the releases in progress for the project."""
+ drafts = await self.candidate_drafts
+ candidates = await self.candidates
+ previews = await self.previews
+ return drafts + candidates + previews
+
class DistributionChannel(sqlmodel.SQLModel, table=True):
id: int = sqlmodel.Field(default=None, primary_key=True)
diff --git a/atr/routes/__init__.py b/atr/routes/__init__.py
index 398f86b..070a076 100644
--- a/atr/routes/__init__.py
+++ b/atr/routes/__init__.py
@@ -381,9 +381,20 @@ def committer(
return decorator
-def format_datetime(timestamp: int) -> str:
- """Format a Unix timestamp into a human readable datetime string."""
- return datetime.datetime.fromtimestamp(timestamp, tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%S")
+def format_datetime(dt_obj: datetime.datetime | int) -> str:
+ """Format a datetime object or Unix timestamp into a human readable datetime string."""
+ # Integers are unix timestamps
+ if isinstance(dt_obj, int):
+ dt_obj = datetime.datetime.fromtimestamp(dt_obj, tz=datetime.UTC)
+
+ # Ensure UTC native timezone awareness
+ if dt_obj.tzinfo is None:
+ dt_obj = dt_obj.replace(tzinfo=datetime.UTC)
+ else:
+ # Convert to UTC if not already
+ dt_obj = dt_obj.astimezone(datetime.UTC)
+
+ return dt_obj.strftime("%Y-%m-%d %H:%M:%S")
def format_file_size(size_in_bytes: int) -> str:
diff --git a/atr/routes/draft.py b/atr/routes/draft.py
index 7965dc3..dfb1081 100644
--- a/atr/routes/draft.py
+++ b/atr/routes/draft.py
@@ -138,6 +138,9 @@ class SvnImportForm(util.QuartFormTyped):
@routes.committer("/draft/add", methods=["GET", "POST"])
async def add(session: routes.CommitterSession) -> response.Response | str:
"""Show a page to allow the user to rsync files to candidate drafts."""
+ # Allow selection of projects via a query parameter
+ preselected_project = quart.request.args.get("project")
+
# Do them outside of the template rendering call to ensure order
# The user_candidate_drafts call can use cached results from user_projects
user_projects = await user.projects(session.uid, committee_only=True)
@@ -155,6 +158,14 @@ async def add(session: routes.CommitterSession) -> response.Response | str:
submit = wtforms.SubmitField("Create candidate draft")
form = await AddForm.create_form()
+
+ if preselected_project and quart.request.method == "GET":
+ # Ensure the preselected project is valid for the user
+ if any(p.name == preselected_project for p in user_projects):
+ form.project_name.data = preselected_project
+ else:
+ await quart.flash(f"Project '{preselected_project}' is not available for selection.", "warning")
+
if quart.request.method == "POST":
if not await form.validate_on_submit():
# TODO: Show the form with errors
@@ -171,6 +182,7 @@ async def add(session: routes.CommitterSession) -> response.Response | str:
candidate_drafts=user_candidate_drafts,
user_projects=sorted_projects,
form=form,
+ preselected_project=preselected_project,
)
diff --git a/atr/routes/projects.py b/atr/routes/projects.py
index ce83c00..0b4f0c6 100644
--- a/atr/routes/projects.py
+++ b/atr/routes/projects.py
@@ -92,7 +92,7 @@ async def add(session: routes.CommitterSession) -> response.Response | str:
wtforms.validators.Length(min=1, max=100),
],
)
- submit = wtforms.SubmitField("Add derived project")
+ submit = wtforms.SubmitField("Add project")
form = await AddForm.create_form()
@@ -102,6 +102,29 @@ async def add(session: routes.CommitterSession) -> response.Response | str:
return await quart.render_template("project-add.html", form=form)
+@routes.committer("/project/choose")
+async def choose(session: routes.CommitterSession) -> str:
+ """Choose a project to work on."""
+ user_projects = []
+ if session.uid:
+ # TODO: Move this filtering logic somewhere else
+ async with db.session() as data:
+ all_projects = await data.project(_committee=True).all()
+ user_projects = [
+ p
+ for p in all_projects
+ if p.committee
+ and (
+ (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 quart.render_template("project-choose.html", user_projects=user_projects)
+
+
@routes.committer("/project/delete", methods=["POST"])
async def delete(session: routes.CommitterSession) -> response.Response:
"""Delete a project created by the user."""
@@ -142,6 +165,19 @@ async def delete(session: routes.CommitterSession) -> response.Response:
return await session.redirect(projects, success=f"Project '{project_name}' deleted successfully.")
+@routes.committer("/project/progress/<project_name>")
+async def progress(session: routes.CommitterSession, project_name: str) -> str:
+ """Show releases in progress for a project."""
+ async with db.session() as data:
+ project = await data.project(name=project_name, _releases=True).demand(
+ base.ASFQuartException(f"Project {project_name} not found", errorcode=404)
+ )
+ releases = await project.releases_in_progress
+ return await quart.render_template(
+ "project-progress.html", project=project, releases=releases, format_datetime=routes.format_datetime
+ )
+
+
@routes.public("/projects")
async def projects() -> str:
"""Main project directory page."""
diff --git a/atr/routes/root.py b/atr/routes/root.py
index a5a30ea..e759fa0 100644
--- a/atr/routes/root.py
+++ b/atr/routes/root.py
@@ -17,12 +17,26 @@
"""root.py"""
+import asfquart.session
import quart
+import werkzeug.wrappers.response as response
import atr.routes as routes
+import atr.routes.projects as projects
+import atr.util as util
@routes.public("/")
-async def index() -> str:
+async def index() -> response.Response | str:
"""Main page."""
- return await quart.render_template("index.html")
+ if await asfquart.session.read():
+ # Redirect to /project/choose
+ return quart.redirect(util.as_url(projects.choose))
+ else:
+ return await quart.render_template("index.html")
+
+
+@routes.public("/tutorial")
+async def tutorial() -> str:
+ """Tutorial page."""
+ return await quart.render_template("tutorial.html")
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index 140eb28..2f5f517 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -383,3 +383,11 @@ span.warning {
.card[data-project-url]:hover {
border-color: gray;
}
+
+.atr-border-dashed {
+ border-style: dashed !important;
+}
+
+.atr-border-dashed:hover {
+ border-color: gray;
+}
diff --git a/atr/templates/index.html b/atr/templates/index.html
index ee8732e..1df1412 100644
--- a/atr/templates/index.html
+++ b/atr/templates/index.html
@@ -6,79 +6,8 @@
{% block content %}
<h1>Apache Trusted Release</h1>
-
- <p>
- ATR is a release management platform for <a href="https://www.apache.org">Apache Software
- Foundation</a> projects. It provides a standardized workflow for PMC members to submit,
- verify, and track release candidates.
- </p>
-
- {% if current_user %}
- <h2>Quick tutorial</h2>
- <p>
- This is a preview of an early version of ATR, and we would like testers to try it out and give us feedback. This section provides a quick tutorial for using ATR. The basic workflow on ATR is Release Candidate Draft -> Release Candidate -> Release Preview -> Release. Note that, as the header says on every page, this is a preview and you cannot yet create actual releases with ATR.
- </p>
-
- <h3>Release candidate draft</h3>
-
- <p>
- We recommend that you start by <a href="{{ as_url(routes.keys.ssh_add) }}">uploading your SSH key</a>. This gives you rsync access which makes it easier to upload your files. We plan to obtain your SSH key from your ASF account via LDAP in the long run.
- </p>
-
- <p>
- Once you've uploaded your SSH key, you may be able to <a href="{{ as_url(routes.draft.add) }}">add a release candidate draft</a>. Only Project Management Committee (PMC) members can do this. Once a draft has been created, all PMC members and committers can add files to the draft or delete files from it.
- </p>
-
- <p>
- When you add files, ATR automatically runs some checks on the files. You'll be able to browse the results of those checks. Note that our checks are currently very basic, and we'll be adding more checks as we get feedback from testers.
- </p>
-
- <p>
- When you're happy with the files in the draft, you can <a href="{{ as_url(routes.draft.promote) }}">promote the draft</a> to a release candidate.
- </p>
-
- <h3>Release candidate</h3>
-
- <p>
- When you've promoted the draft to a release candidate, you can use ATR to <a href="{{ as_url(routes.candidate.vote) }}">start a vote on the release candidate</a>. Currently we allow any PMC member to start the vote, but in the future we may limit this to designated release managers. The ATR is designed to send the vote email itself, but we understand that projects send very detailed vote announcement emails. We plan to make it easier for you to send such announcement emails. We als [...]
- </p>
-
- <p>
- The vote email is not actually sent out, because ATR cannot yet be used to create releases. We are only testing the workflow.
- </p>
-
- <p>
- When you're happy with the release candidate, you can <a href="{{ as_url(routes.candidate.resolve) }}">record the vote resolution</a> to promote the release candidate to a release preview.
- </p>
-
- <h3>Release preview</h3>
-
- <p>
- When you've promoted the release candidate to a release preview, you can review the files. We plan to make it possible to adjust the release preview before it's promoted to a release.
- </p>
-
- <p>
- When you're happy with the release preview, you can <a href="{{ as_url(routes.preview.promote) }}">promote the release preview</a> to a release. This, again, should be an action limited to designated release managers.
- </p>
-
- <h3>Release</h3>
-
- <p>
- When you've promoted the release preview to a release, you can <a href="{{ as_url(routes.release.releases) }}">browse the release</a>.
- </p>
-
- <h2>Key features</h2>
- <ul>
- <li>Support for rsync or HTML form based file uploads</li>
- <li>Automatic checks of release artifacts</li>
- <li>Templated email vote announcements</li>
- </ul>
- {% else %}
-
- <div class="alert alert-info d-flex align-items-center" role="alert">
- <i class="fas fa-exclamation-triangle me-2"></i>
- <div>Note: You need to login with your ASF account in order to use this platform.</div>
- </div>
- {% endif %}
-
+ <div class="alert alert-info d-flex align-items-center" role="alert">
+ <i class="fas fa-info-circle me-2"></i>
+ <div>Please login with your ASF account in order to use this platform.</div>
+ </div>
{% endblock content %}
diff --git a/atr/templates/project-add.html b/atr/templates/project-add.html
index b5a931c..437074f 100644
--- a/atr/templates/project-add.html
+++ b/atr/templates/project-add.html
@@ -1,7 +1,7 @@
{% extends "layouts/base.html" %}
{% block title %}
- Add project ~ ATR
+ Add a new project ~ ATR
{% endblock title %}
{% block description %}
@@ -9,8 +9,8 @@
{% endblock description %}
{% block content %}
- <h1>Add derived project</h1>
- <p class="intro">Derive a new project from an existing one, by adding a suffix.</p>
+ <h1>Add a new project</h1>
+ <p class="intro">New projects can only be derived from existing projects, by adding a suffix.</p>
<form method="post" class="atr-canary py-4">
{{ form.hidden_tag() }}
@@ -42,7 +42,7 @@
<div class="mb-3 pb-3 row border-bottom">
<label id="new-project-name-label"
for="new-project-name-display"
- class="col-sm-3 col-form-label text-sm-end">New project name:</label>
+ class="col-sm-3 col-form-label text-sm-end">Project name preview:</label>
<div class="col-sm-8">
<code id="new-project-name-display"
class="form-control-plaintext bg-light p-2 rounded d-block"></code>
@@ -53,7 +53,7 @@
<div class="mb-3 pb-3 row border-bottom">
<label id="new-project-label-label"
for="new-project-label-display"
- class="col-sm-3 col-form-label text-sm-end">New project label:</label>
+ class="col-sm-3 col-form-label text-sm-end">Project label preview:</label>
<div class="col-sm-8">
<code id="new-project-label-display"
class="form-control-plaintext bg-light p-2 rounded d-block"></code>
diff --git a/atr/templates/project-choose.html b/atr/templates/project-choose.html
new file mode 100644
index 0000000..9db31bb
--- /dev/null
+++ b/atr/templates/project-choose.html
@@ -0,0 +1,47 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Choose a project ~ ATR
+{% endblock title %}
+
+{% block content %}
+
+ <h1>Choose a project</h1>
+ {% if user_projects %}
+ <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
+ {% for project in user_projects %}
+ <div class="col">
+ <a href="{{ as_url(routes.projects.progress, project_name=project.name) }}"
+ class="text-decoration-none">
+ {# TODO: We're only setting data-project-url for style #}
+ <div class="card h-100 shadow-sm atr-cursor-pointer hover-lift project-card {{ 'bg-body-secondary' if project.is_retired else 'bg-body-tertiary-hover' }}"
+ data-project-url="{{ as_url(routes.draft.add, project=project.name) }}">
+ <div class="card-body">
+ <h3 class="card-title fs-5 mb-2">{{ project.display_name }}</h3>
+ <h4 class="card-subtitle mb-2 text-muted fs-6">{{ project.name }}</h4>
+ </div>
+ </div>
+ </a>
+ </div>
+ {% endfor %}
+
+ {# Pseudocard to add a project #}
+ <div class="col">
+ <a href="{{ as_url(routes.projects.add) }}" class="text-decoration-none">
+ <div class="card h-100 shadow-sm atr-cursor-pointer hover-lift atr-border-dashed">
+ <div class="card-body d-flex flex-column justify-content-center align-items-center text-center">
+ <i class="fas fa-plus text-secondary fa-2x mb-2"></i>
+ <h5 class="card-title text-body-secondary fs-6">Add a new project</h5>
+ </div>
+ </div>
+ </a>
+ </div>
+
+ </div>
+ {% else %}
+ <div class="alert alert-warning" role="alert">
+ You are not currently listed as a participant (committer, PMC member, or RM) in any projects known to ATR.
+ You may need to <a href="{{ as_url(routes.projects.add) }}">add a project</a> or check your committee memberships.
+ </div>
+ {% endif %}
+{% endblock content %}
diff --git a/atr/templates/project-progress.html b/atr/templates/project-progress.html
new file mode 100644
index 0000000..967d199
--- /dev/null
+++ b/atr/templates/project-progress.html
@@ -0,0 +1,72 @@
+{% extends "layouts/base.html" %}
+
+{% block title %}
+ Releases in progress for {{ project.display_name }} ~ ATR
+{% endblock title %}
+
+{% block content %}
+ <p>
+ <a href="{{ as_url(routes.projects.choose) }}" class="back-link">← Back to Choose a project</a>
+ </p>
+ <h1>Choose a release</h1>
+
+ {% if releases %}
+ <p>
+ Releases in progress for <strong>{{ project.display_name }}</strong>:
+ </p>
+ {% else %}
+ <p>
+ There are no releases currently in progress for <strong>{{ project.display_name }}</strong>.
+ </p>
+ {% endif %}
+
+ <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
+ {% if releases %}
+
+ {% for release in releases %}
+ {% set phase = release.phase.value %}
+ {% set target_url = None %}
+ {% if phase == 'release_candidate_draft' %}
+ {% set target_url = as_url(routes.draft.evaluate, project_name=project.name, version_name=release.version) %}
+ {% set badge_class = "bg-primary" %}
+ {% elif phase == 'release_candidate_before_vote' or phase == 'release_candidate_during_vote' %}
+ {% set target_url = as_url(routes.candidate.view, project_name=project.name, version_name=release.version) %}
+ {% set badge_class = "bg-warning text-dark" %}
+ {% elif phase == 'release_preview' %}
+ {% set target_url = as_url(routes.preview.view, project_name=project.name, version_name=release.version) %}
+ {% set badge_class = "bg-success" %}
+ {% endif %}
+
+ {% if target_url %}
+ <div class="col">
+ <a href="{{ target_url }}" class="text-decoration-none">
+ <div class="card h-100 shadow-sm atr-cursor-pointer hover-lift project-card bg-body-tertiary-hover">
+ <div class="card-body position-relative">
+ <div class="position-absolute top-0 end-0 p-2">
+ <span class="badge {{ badge_class }}">{{ phase|replace('_', ' ') |title }}</span>
+ </div>
+ <h3 class="card-title fs-5 mb-2">{{ release.version }}</h3>
+ <p class="card-text text-muted small">Created: {{ format_datetime(release.created) }}</p>
+ </div>
+ </div>
+ </a>
+ </div>
+ {% endif %}
+ {% endfor %}
+ {% else %}
+ {% endif %}
+
+ <div class="col">
+ <a href="{{ as_url(routes.draft.add, project=project.name) }}"
+ class="text-decoration-none">
+ <div class="card h-100 shadow-sm atr-cursor-pointer hover-lift atr-border-dashed">
+ <div class="card-body d-flex flex-column justify-content-center align-items-center text-center">
+ <i class="fas fa-plus text-secondary fa-2x mb-2"></i>
+ <h5 class="card-title text-body-secondary fs-6">Add a release draft</h5>
+ </div>
+ </div>
+ </a>
+ </div>
+ </div>
+
+{% endblock content %}
diff --git a/atr/templates/index.html b/atr/templates/tutorial.html
similarity index 100%
copy from atr/templates/index.html
copy to atr/templates/tutorial.html
diff --git a/playwright/test.py b/playwright/test.py
index e88796f..d944fb9 100644
--- a/playwright/test.py
+++ b/playwright/test.py
@@ -792,10 +792,10 @@ def test_login(page: sync_api.Page, credentials: Credentials) -> None:
logging.info("Page loaded after login")
logging.info(f"Initial URL after login: {page.url}")
- logging.info("Waiting for the redirect to /")
+ logging.info("Waiting for the redirect to /project/choose")
# We can't use wait_for_path here because it goes through /auth
- page.wait_for_url("https://*/")
- logging.info("Redirected to /")
+ page.wait_for_url("https://*/project/choose")
+ logging.info("Redirected to /project/choose")
logging.info(f"Page URL: {page.url}")
logging.info("Login actions completed successfully")
@@ -841,7 +841,7 @@ def test_projects_03_add_project(page: sync_api.Page, credentials: Credentials)
logging.info("Navigating to the add derived project page")
go_to_path(page, "/project/add")
- logging.info("Add derived project page loaded")
+ logging.info("Add a new project page loaded")
logging.info(f"Selecting base project '{base_project_option_label}'")
page.locator('select[name="project_name"]').select_option(label=base_project_option_label)
@@ -850,7 +850,7 @@ def test_projects_03_add_project(page: sync_api.Page, credentials: Credentials)
page.locator('input[name="derived_project_name"]').fill(derived_project_input_value)
logging.info("Submitting the add derived project form")
- submit_button_locator = page.locator('input[type="submit"][value="Add derived project"]')
+ submit_button_locator = page.locator('input[type="submit"][value="Add project"]')
sync_api.expect(submit_button_locator).to_be_enabled()
submit_button_locator.click()
@@ -866,7 +866,7 @@ def test_projects_03_add_project(page: sync_api.Page, credentials: Credentials)
def test_ssh_01_add_key(page: sync_api.Page, credentials: Credentials) -> None:
logging.info("Starting SSH key addition test")
- go_to_path(page, "/")
+ go_to_path(page, "/committees")
logging.info("Navigating to Your Public Keys page")
page.locator('a[href="/keys"]:has-text("Your public keys")').click()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org