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 75d456f Show only options on the vote page, and move checks to a new
page
75d456f is described below
commit 75d456f5faefefe752cb5f1acddd0ce12e60fe36
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Dec 3 20:48:39 2025 +0000
Show only options on the vote page, and move checks to a new page
---
atr/admin/templates/all-releases.html | 2 +-
atr/admin/templates/validation.html | 2 +-
atr/db/interaction.py | 4 +-
atr/get/__init__.py | 2 +
atr/get/checks.py | 503 +++++++++++++++++++++++
atr/get/file.py | 2 +-
atr/get/keys.py | 6 +-
atr/get/test.py | 33 ++
atr/get/vote.py | 573 +++++++++++++++++++++++----
atr/htm.py | 2 +-
atr/static/css/atr.css | 11 +
atr/template.py | 6 +-
atr/templates/blank.html | 3 +
atr/templates/check-selected-path-table.html | 2 +-
atr/templates/committee-view.html | 2 +-
atr/templates/includes/sidebar.html | 4 +-
16 files changed, 1058 insertions(+), 99 deletions(-)
diff --git a/atr/admin/templates/all-releases.html
b/atr/admin/templates/all-releases.html
index 7c6673d..071782f 100644
--- a/atr/admin/templates/all-releases.html
+++ b/atr/admin/templates/all-releases.html
@@ -11,7 +11,7 @@
{% block content %}
<h1>All releases</h1>
- <table class="table table-striped table-hover">
+ <table class="table table-striped">
<thead>
<tr>
<th>Release name</th>
diff --git a/atr/admin/templates/validation.html
b/atr/admin/templates/validation.html
index 6adb110..a57ea85 100644
--- a/atr/admin/templates/validation.html
+++ b/atr/admin/templates/validation.html
@@ -14,7 +14,7 @@
{% if divergences|length == 0 %}
<p>No validation errors were found.</p>
{% else %}
- <table class="table table-striped table-hover">
+ <table class="table table-striped">
<thead>
<tr>
<th>Components</th>
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index d0a26c3..e59aebb 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -237,13 +237,13 @@ async def previews(project: sql.Project) ->
list[sql.Release]:
return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
-async def release_latest_vote_task(release: sql.Release) -> sql.Task | None:
+async def release_latest_vote_task(release: sql.Release, caller_data:
db.Session | None = None) -> sql.Task | None:
"""Find the most recent VOTE_INITIATE task for this release."""
disallowed_statuses = [sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE]
if util.is_dev_environment():
disallowed_statuses = []
via = sql.validate_instrumented_attribute
- async with db.session() as data:
+ async with db.ensure_session(caller_data) as data:
query = (
sqlmodel.select(sql.Task)
.where(sql.Task.project_name == release.project_name)
diff --git a/atr/get/__init__.py b/atr/get/__init__.py
index 0273f3c..103e9e3 100644
--- a/atr/get/__init__.py
+++ b/atr/get/__init__.py
@@ -21,6 +21,7 @@ from typing import Final, Literal
import atr.get.announce as announce
import atr.get.candidate as candidate
+import atr.get.checks as checks
import atr.get.committees as committees
import atr.get.compose as compose
import atr.get.distribution as distribution
@@ -55,6 +56,7 @@ ROUTES_MODULE: Final[Literal[True]] = True
__all__ = [
"announce",
"candidate",
+ "checks",
"committees",
"compose",
"distribution",
diff --git a/atr/get/checks.py b/atr/get/checks.py
new file mode 100644
index 0000000..47c2879
--- /dev/null
+++ b/atr/get/checks.py
@@ -0,0 +1,503 @@
+# 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 pathlib
+from collections.abc import Callable
+from typing import NamedTuple
+
+import asfquart.base as base
+import htpy
+
+import atr.blueprints.get as get
+import atr.db as db
+import atr.get.download as download
+import atr.get.ignores as ignores
+import atr.get.report as report
+import atr.get.vote as vote
+import atr.htm as htm
+import atr.models.sql as sql
+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
+
+
+class FileStats(NamedTuple):
+ file_pass_before: int
+ file_warn_before: int
+ file_err_before: int
+ file_pass_after: int
+ file_warn_after: int
+ file_err_after: int
+ member_pass_before: int
+ member_warn_before: int
+ member_err_before: int
+ member_pass_after: int
+ member_warn_after: int
+ member_err_after: int
+
+ @property
+ def total_pass_before(self) -> int:
+ return self.file_pass_before + self.member_pass_before
+
+ @property
+ def total_warn_before(self) -> int:
+ return self.file_warn_before + self.member_warn_before
+
+ @property
+ def total_err_before(self) -> int:
+ return self.file_err_before + self.member_err_before
+
+ @property
+ def total_pass_after(self) -> int:
+ return self.file_pass_after + self.member_pass_after
+
+ @property
+ def total_warn_after(self) -> int:
+ return self.file_warn_after + self.member_warn_after
+
+ @property
+ def total_err_after(self) -> int:
+ return self.file_err_after + self.member_err_after
+
+
+async def get_file_totals(release: sql.Release, session: web.Committer | None)
-> FileStats:
+ """Get file level check totals after ignores are applied."""
+ if release.committee is None:
+ raise ValueError("Release has no committee")
+
+ base_path = util.release_directory(release)
+ paths = [path async for path in util.paths_recursive(base_path)]
+
+ async with storage.read(session) as read:
+ ragp = read.as_general_public()
+ match_ignore = await
ragp.checks.ignores_matcher(release.committee.name)
+
+ _, totals = await _compute_stats(release, paths, match_ignore)
+ return totals
+
+
[email protected]("/checks/<project_name>/<version_name>")
+async def selected(session: web.Committer | None, project_name: str,
version_name: str) -> str:
+ """Show the file checks for a release candidate."""
+ async with db.session() as data:
+ release = await data.release(
+ project_name=project_name,
+ version=version_name,
+ phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+ _committee=True,
+ ).demand(base.ASFQuartException("Release does not exist",
errorcode=404))
+
+ if release.committee is None:
+ raise ValueError("Release has no committee")
+
+ base_path = util.release_directory(release)
+ paths = [path async for path in util.paths_recursive(base_path)]
+ paths.sort()
+
+ async with storage.read(session) as read:
+ ragp = read.as_general_public()
+ match_ignore = await
ragp.checks.ignores_matcher(release.committee.name)
+
+ per_file_stats, totals = await _compute_stats(release, paths, match_ignore)
+
+ page = htm.Block()
+ _render_header(page, release)
+ _render_summary(page, totals, paths, per_file_stats)
+ _render_checks_table(page, release, paths, per_file_stats)
+ _render_ignores_section(page, release)
+ _render_debug_table(page, paths, per_file_stats)
+
+ return await template.blank(
+ f"File checks for {release.project.short_display_name}
{release.version}",
+ content=page.collect(),
+ )
+
+
+async def _compute_stats( # noqa: C901
+ release: sql.Release,
+ paths: list[pathlib.Path],
+ match_ignore: Callable[[sql.CheckResult], bool],
+) -> tuple[dict[pathlib.Path, FileStats], FileStats]:
+ per_file: dict[pathlib.Path, dict[str, int]] = {
+ p: {
+ "file_pass_before": 0,
+ "file_warn_before": 0,
+ "file_err_before": 0,
+ "file_pass_after": 0,
+ "file_warn_after": 0,
+ "file_err_after": 0,
+ "member_pass_before": 0,
+ "member_warn_before": 0,
+ "member_err_before": 0,
+ "member_pass_after": 0,
+ "member_warn_after": 0,
+ "member_err_after": 0,
+ }
+ for p in paths
+ }
+
+ if release.latest_revision_number is None:
+ # TODO: Or raise an exception?
+ empty_stats = FileStats(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
+ return {p: empty_stats for p in paths}, empty_stats
+
+ async with db.session() as data:
+ check_results = await data.check_result(
+ release_name=release.name,
+ revision_number=release.latest_revision_number,
+ ).all()
+
+ for cr in check_results:
+ if not cr.primary_rel_path:
+ continue
+
+ file_path = pathlib.Path(cr.primary_rel_path)
+ if file_path not in per_file:
+ continue
+
+ is_member = cr.member_rel_path is not None
+ is_ignored = match_ignore(cr)
+ prefix = "member" if is_member else "file"
+
+ if cr.status == sql.CheckResultStatus.SUCCESS:
+ per_file[file_path][f"{prefix}_pass_before"] += 1
+ per_file[file_path][f"{prefix}_pass_after"] += 1
+ elif cr.status == sql.CheckResultStatus.WARNING:
+ per_file[file_path][f"{prefix}_warn_before"] += 1
+ if not is_ignored:
+ per_file[file_path][f"{prefix}_warn_after"] += 1
+ else:
+ per_file[file_path][f"{prefix}_err_before"] += 1
+ if not is_ignored:
+ per_file[file_path][f"{prefix}_err_after"] += 1
+
+ per_file_stats = {p: FileStats(**c) for p, c in per_file.items()}
+
+ total_counts = {
+ "file_pass_before": 0,
+ "file_warn_before": 0,
+ "file_err_before": 0,
+ "file_pass_after": 0,
+ "file_warn_after": 0,
+ "file_err_after": 0,
+ "member_pass_before": 0,
+ "member_warn_before": 0,
+ "member_err_before": 0,
+ "member_pass_after": 0,
+ "member_warn_after": 0,
+ "member_err_after": 0,
+ }
+ for stats in per_file_stats.values():
+ for field in total_counts:
+ total_counts[field] += getattr(stats, field)
+
+ return per_file_stats, FileStats(**total_counts)
+
+
+def _render_checks_table(
+ page: htm.Block,
+ release: sql.Release,
+ paths: list[pathlib.Path],
+ per_file_stats: dict[pathlib.Path, FileStats],
+) -> None:
+ if not paths:
+ page.div(".alert.alert-info")["This release candidate does not have
any files."]
+ return
+
+ table = htm.Block(htpy.table,
classes=".table.table-striped.align-middle.table-sm.mb-0.border")
+
+ thead = htm.Block(htpy.thead, classes=".table-light")
+ # TODO: We forbid inline styles in Jinja2 through linting
+ # But we use it here
+ # It is convenient, and we should consider whether or not to allow it
+ thead.tr[
+ htpy.th(".py-2.ps-3")["Path"],
+ htpy.th(".py-2.text-center", style="width: 5em")["Pass"],
+ htpy.th(".py-2.text-center", style="width: 5em")["Warning"],
+ htpy.th(".py-2.text-center", style="width: 5em")["Error"],
+ htpy.th(".py-2.text-end.pe-3")[""],
+ ]
+ table.append(thead.collect())
+
+ tbody = htm.Block(htpy.tbody)
+ for path in paths:
+ stats = per_file_stats.get(path, FileStats(0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0))
+ _render_file_row(tbody, release, path, stats)
+ table.append(tbody.collect())
+
+ page.div(".table-responsive.card.mb-4")[table.collect()]
+
+
+def _render_debug_table(
+ page: htm.Block,
+ paths: list[pathlib.Path],
+ per_file_stats: dict[pathlib.Path, FileStats],
+) -> None:
+ # Bootstrap does have striping, but that's for horizontal stripes
+ # These are vertical stripes, to make it easier to distinguish collections
+ stripe_a = "background-color: #f0f0f0; text-align: center;"
+ stripe_b = "background-color: #ffffff; text-align: center;"
+
+ table = htm.Block(htpy.table,
classes=".table.table-bordered.table-sm.mb-0.text-center")
+
+ thead = htm.Block(htpy.thead, classes=".table-light")
+ thead.tr[
+ htpy.th(rowspan="2", style="text-align: center; vertical-align:
middle;")["Path"],
+ htpy.th(colspan="3", style=stripe_a)["File (before)"],
+ htpy.th(colspan="3", style=stripe_b)["File (after)"],
+ htpy.th(colspan="3", style=stripe_a)["Member (before)"],
+ htpy.th(colspan="3", style=stripe_b)["Member (after)"],
+ htpy.th(colspan="3", style=stripe_a)["Total (before)"],
+ htpy.th(colspan="3", style=stripe_b)["Total (after)"],
+ ]
+ thead.tr[
+ htpy.th(style=stripe_a)["P"],
+ htpy.th(style=stripe_a)["W"],
+ htpy.th(style=stripe_a)["E"],
+ htpy.th(style=stripe_b)["P"],
+ htpy.th(style=stripe_b)["W"],
+ htpy.th(style=stripe_b)["E"],
+ htpy.th(style=stripe_a)["P"],
+ htpy.th(style=stripe_a)["W"],
+ htpy.th(style=stripe_a)["E"],
+ htpy.th(style=stripe_b)["P"],
+ htpy.th(style=stripe_b)["W"],
+ htpy.th(style=stripe_b)["E"],
+ htpy.th(style=stripe_a)["P"],
+ htpy.th(style=stripe_a)["W"],
+ htpy.th(style=stripe_a)["E"],
+ htpy.th(style=stripe_b)["P"],
+ htpy.th(style=stripe_b)["W"],
+ htpy.th(style=stripe_b)["E"],
+ ]
+ table.append(thead.collect())
+
+ tbody = htm.Block(htpy.tbody)
+ for path in paths:
+ stats = per_file_stats.get(path, FileStats(0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0))
+ tbody.tr[
+ htpy.td(class_="text-start")[htpy.code[str(path)]],
+ htpy.td(style=stripe_a)[str(stats.file_pass_before)],
+ htpy.td(style=stripe_a)[str(stats.file_warn_before)],
+ htpy.td(style=stripe_a)[str(stats.file_err_before)],
+ htpy.td(style=stripe_b)[str(stats.file_pass_after)],
+ htpy.td(style=stripe_b)[str(stats.file_warn_after)],
+ htpy.td(style=stripe_b)[str(stats.file_err_after)],
+ htpy.td(style=stripe_a)[str(stats.member_pass_before)],
+ htpy.td(style=stripe_a)[str(stats.member_warn_before)],
+ htpy.td(style=stripe_a)[str(stats.member_err_before)],
+ htpy.td(style=stripe_b)[str(stats.member_pass_after)],
+ htpy.td(style=stripe_b)[str(stats.member_warn_after)],
+ htpy.td(style=stripe_b)[str(stats.member_err_after)],
+ htpy.td(style=stripe_a)[str(stats.total_pass_before)],
+ htpy.td(style=stripe_a)[str(stats.total_warn_before)],
+ htpy.td(style=stripe_a)[str(stats.total_err_before)],
+ htpy.td(style=stripe_b)[str(stats.total_pass_after)],
+ htpy.td(style=stripe_b)[str(stats.total_warn_after)],
+ htpy.td(style=stripe_b)[str(stats.total_err_after)],
+ ]
+ table.append(tbody.collect())
+
+ page.append(
+ htpy.details(".mt-4")[
+ htpy.summary["All statistics"],
+ htpy.div(".table-responsive.mt-3")[table.collect()],
+ ]
+ )
+
+
+def _render_file_row(
+ tbody: htm.Block,
+ release: sql.Release,
+ path: pathlib.Path,
+ stats: FileStats,
+) -> None:
+ path_str = str(path)
+ num_style = "font-size: 1.1rem;"
+
+ pass_count = stats.file_pass_after
+ warn_count = stats.file_warn_after
+ err_count = stats.file_err_after
+ has_checks_before = (stats.file_pass_before + stats.file_warn_before +
stats.file_err_before) > 0
+ has_checks_after = (pass_count + warn_count + err_count) > 0
+
+ report_url = util.as_url(
+ report.selected_path,
+ project_name=release.project.name,
+ version_name=release.version,
+ rel_path=path_str,
+ )
+ download_url = util.as_url(
+ download.path,
+ project_name=release.project.name,
+ version_name=release.version,
+ file_path=path_str,
+ )
+
+ if not has_checks_before:
+ path_display = htpy.code(".text-muted")[path_str]
+ pass_cell = htpy.span(".text-muted", style=num_style)["-"]
+ warn_cell = htpy.span(".text-muted", style=num_style)["-"]
+ err_cell = htpy.span(".text-muted", style=num_style)["-"]
+ report_btn =
htpy.span(".btn.btn-sm.btn-outline-secondary.disabled")["No checks"]
+ elif not has_checks_after:
+ path_display = htpy.code[path_str]
+ pass_cell = htpy.span(".text-muted", style=num_style)["0"]
+ warn_cell = htpy.span(".text-muted", style=num_style)["0"]
+ err_cell = htpy.span(".text-muted", style=num_style)["0"]
+ report_btn = htpy.a(".btn.btn-sm.btn-outline-secondary",
href=report_url)["Show details"]
+ elif err_count > 0:
+ path_display = htpy.strong[htpy.code(".text-danger")[path_str]]
+ pass_cell = (
+ htpy.span(".text-success", style=num_style)[str(pass_count)]
+ if pass_count > 0
+ else htpy.span(".text-muted", style=num_style)["0"]
+ )
+ warn_cell = (
+ htpy.span(".text-warning", style=num_style)[str(warn_count)]
+ if warn_count > 0
+ else htpy.span(".text-muted", style=num_style)["0"]
+ )
+ err_cell = htpy.span(".text-danger.fw-bold",
style=num_style)[str(err_count)]
+ report_btn = htpy.a(".btn.btn-sm.btn-outline-danger",
href=report_url)["Show details"]
+ elif warn_count > 0:
+ path_display = htpy.strong[htpy.code(".text-warning")[path_str]]
+ pass_cell = (
+ htpy.span(".text-success", style=num_style)[str(pass_count)]
+ if pass_count > 0
+ else htpy.span(".text-muted", style=num_style)["0"]
+ )
+ warn_cell = htpy.span(".text-warning.fw-bold",
style=num_style)[str(warn_count)]
+ err_cell = htpy.span(".text-muted", style=num_style)["0"]
+ report_btn = htpy.a(".btn.btn-sm.btn-outline-warning",
href=report_url)["Show details"]
+ else:
+ path_display = htpy.code[path_str]
+ pass_cell = htpy.span(".text-success",
style=num_style)[str(pass_count)]
+ warn_cell = htpy.span(".text-muted", style=num_style)["0"]
+ err_cell = htpy.span(".text-muted", style=num_style)["0"]
+ report_btn = htpy.a(".btn.btn-sm.btn-outline-success",
href=report_url)["Show details"]
+
+ download_btn = htpy.a(".btn.btn-sm.btn-outline-secondary",
href=download_url)["Download"]
+
+ tbody.tr[
+ htpy.td(".py-2.ps-3")[path_display],
+ htpy.td(".py-2.text-center")[pass_cell],
+ htpy.td(".py-2.text-center")[warn_cell],
+ htpy.td(".py-2.text-center")[err_cell],
+ htpy.td(".text-end.text-nowrap.py-2.pe-3")[
+ htpy.div(".d-flex.justify-content-end.align-items-center.gap-2")[
+ report_btn,
+ download_btn,
+ ],
+ ],
+ ]
+
+
+def _render_header(page: htm.Block, release: sql.Release) -> None:
+ shared.distribution.html_nav(
+ page,
+ back_url=util.as_url(vote.selected, project_name=release.project.name,
version_name=release.version),
+ back_anchor=f"Vote on {release.project.short_display_name}
{release.version}",
+ phase="VOTE",
+ )
+
+ page.h1[
+ "File checks for ",
+ htm.strong[release.project.short_display_name],
+ " ",
+ htm.em[release.version],
+ ]
+
+
+def _render_ignores_section(page: htm.Block, release: sql.Release) -> None:
+ if release.committee is None:
+ return
+
+ # TODO: We should choose a consistent " ..." or "... " style
+ page.h2["Check ignores"]
+ page.p[
+ "Committee members can configure rules to ignore specific check
results. "
+ "Ignored checks are excluded from the counts shown above.",
+ ]
+ ignores_url = util.as_url(ignores.ignores,
committee_name=release.committee.name)
+ page.div[htpy.a(".btn.btn-outline-primary", href=ignores_url)["Manage
check ignores"],]
+
+
+def _render_summary(
+ page: htm.Block,
+ totals: FileStats,
+ paths: list[pathlib.Path],
+ per_file_stats: dict[pathlib.Path, FileStats],
+) -> None:
+ files_with_errors = sum(1 for s in per_file_stats.values() if
s.file_err_after > 0)
+ files_with_warnings = sum(1 for s in per_file_stats.values() if
(s.file_warn_after > 0) and (s.file_err_after == 0))
+ files_passed = sum(
+ 1
+ for s in per_file_stats.values()
+ if (s.file_pass_after > 0) and (s.file_warn_after == 0) and
(s.file_err_after == 0)
+ )
+ files_skipped = len(paths) - files_passed - files_with_warnings -
files_with_errors
+
+ file_word = "file" if (len(paths) == 1) else "files"
+ passed_word = "file passed" if (files_passed == 1) else "files passed"
+ warn_file_word = "file has" if (files_with_warnings == 1) else "files have"
+ err_file_word = "file has" if (files_with_errors == 1) else "files have"
+ skipped_word = "file did not require checking" if (files_skipped == 1)
else "files did not require checking"
+ no_errors_word = "no" if ((files_passed > 0) or (files_with_warnings > 0))
else "No"
+
+ page.p[
+ f"Showing check results for {len(paths)} {file_word}. ",
+ f"{files_passed} {passed_word} all checks, " if (files_passed > 0)
else "",
+ f"{files_with_warnings} {warn_file_word} warnings, " if
(files_with_warnings > 0) else "",
+ f"{files_with_errors} {err_file_word} errors."
+ if (files_with_errors > 0)
+ else f"{no_errors_word} files have errors.",
+ f" {files_skipped} {skipped_word}." if (files_skipped > 0) else "",
+ ]
+
+ check_word = "check" if (totals.file_pass_after == 1) else "checks"
+ warn_word = "warning" if (totals.file_warn_after == 1) else "warnings"
+ err_word = "error" if (totals.file_err_after == 1) else "errors"
+
+ summary_div = htm.Block(htm.div, classes=".d-flex.flex-wrap.gap-4.mb-3")
+ summary_div.span(".text-success")[
+ htpy.i(".bi.bi-check-circle-fill.me-2"),
+ f"{totals.file_pass_after} {check_word} passed",
+ ]
+ if totals.file_warn_after > 0:
+ summary_div.span(".text-warning")[
+ htpy.i(".bi.bi-exclamation-triangle-fill.me-2"),
+ f"{totals.file_warn_after} {warn_word}",
+ ]
+ else:
+ summary_div.span(".text-muted")[
+ htpy.i(".bi.bi-exclamation-triangle.me-2"),
+ "0 warnings",
+ ]
+ if totals.file_err_after > 0:
+ summary_div.span(".text-danger")[
+ htpy.i(".bi.bi-x-circle-fill.me-2"),
+ f"{totals.file_err_after} {err_word}",
+ ]
+ else:
+ summary_div.span(".text-muted")[
+ htpy.i(".bi.bi-x-circle.me-2"),
+ "0 errors",
+ ]
+ page.append(summary_div.collect())
diff --git a/atr/get/file.py b/atr/get/file.py
index fcbc758..bb56d9d 100644
--- a/atr/get/file.py
+++ b/atr/get/file.py
@@ -102,7 +102,7 @@ async def selected(session: web.Committer, project_name:
str, version_name: str)
files_card.div(".card-body")[
htm.div(".table-responsive")[
- htm.table(".table.table-striped.table-hover")[
+ htm.table(".table.table-striped")[
htm.thead[
htm.tr[
htm.th["Permissions"],
diff --git a/atr/get/keys.py b/atr/get/keys.py
index da1f174..d969200 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -312,7 +312,7 @@ def _committee_keys(page: htm.Block,
user_committees_with_keys: list[sql.Committ
tbody.append(row.collect())
page.div(".table-responsive.mb-2")[
-
htm.table(".table.border.table-striped.table-hover.table-sm")[thead,
tbody.collect()]
+ htm.table(".table.border.table-striped.table-sm")[thead,
tbody.collect()]
]
page.p(".text-muted")[
"The ",
@@ -399,9 +399,7 @@ def _openpgp_keys(page: htm.Block, user_keys:
list[sql.PublicSigningKey]) -> Non
)
tbody.append(row.collect())
- page.div(".table-responsive.mb-5")[
-
htm.table(".table.border.table-striped.table-hover.table-sm")[thead,
tbody.collect()]
- ]
+
page.div(".table-responsive.mb-5")[htm.table(".table.border.table-striped.table-sm")[thead,
tbody.collect()]]
else:
page.p[htm.strong["You haven't added any personal OpenPGP keys yet."]]
diff --git a/atr/get/test.py b/atr/get/test.py
index 3f3bf80..adf26cb 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -22,7 +22,9 @@ import atr.blueprints.get as get
import atr.config as config
import atr.form as form
import atr.get.root as root
+import atr.get.vote as vote
import atr.htm as htm
+import atr.models.sql as sql
import atr.shared as shared
import atr.template as template
import atr.web as web
@@ -115,3 +117,34 @@ async def test_single(session: web.Committer | None) ->
str:
]
return await template.blank(title="Test single form", content=forms_html)
+
+
[email protected]("/test/vote/<category>/<project_name>/<version_name>")
+async def test_vote(session: web.Committer | None, category: str,
project_name: str, version_name: str) -> str:
+ if not config.get().ALLOW_TESTS:
+ raise base.ASFQuartException("Test routes not enabled", errorcode=404)
+
+ category_map = {
+ "unauthenticated": vote.UserCategory.UNAUTHENTICATED,
+ "committer": vote.UserCategory.COMMITTER,
+ "committer_rm": vote.UserCategory.COMMITTER_RM,
+ "pmc_member": vote.UserCategory.PMC_MEMBER,
+ "pmc_member_rm": vote.UserCategory.PMC_MEMBER_RM,
+ }
+
+ user_category = category_map.get(category.lower())
+ if user_category is None:
+ raise base.ASFQuartException(
+ f"Invalid category: {category}. Valid options: {',
'.join(category_map.keys())}",
+ errorcode=400,
+ )
+
+ if (user_category != vote.UserCategory.UNAUTHENTICATED) and (session is
None):
+ raise base.ASFQuartException("You must be logged in to preview
authenticated views", errorcode=401)
+
+ _, release, latest_vote_task = await vote.category_and_release(session,
project_name, version_name)
+
+ if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
+ raise base.ASFQuartException("Release is not a candidate",
errorcode=404)
+
+ return await vote.render_options_page(session, release, user_category,
latest_vote_task)
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 98eb936..6bd8095 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -15,124 +15,531 @@
# specific language governing permissions and limitations
# under the License.
+from __future__ import annotations
+
+import enum
+import urllib.parse
+from typing import TYPE_CHECKING
+
import asfquart.base as base
import htpy
import atr.blueprints.get as get
+import atr.config as config
import atr.db as db
import atr.db.interaction as interaction
import atr.form as form
-import atr.forms as forms
-import atr.log as log
+import atr.get.download as download
+import atr.get.keys as keys
+import atr.get.root as root
+import atr.htm as htm
import atr.mapping as mapping
-import atr.models.results as results
import atr.models.sql as sql
+import atr.post as post
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 atr.get.checks as checks
[email protected]("/vote/<project_name>/<version_name>")
-async def selected(session: web.Committer | None, project_name: str,
version_name: str) -> web.WerkzeugResponse | str:
- """Show the contents of the release candidate draft."""
+
+class UserCategory(str, enum.Enum):
+ COMMITTER = "Committer"
+ COMMITTER_RM = "Committer (Release Manager)"
+ PMC_MEMBER = "PMC Member"
+ PMC_MEMBER_RM = "PMC Member (Release Manager)"
+ UNAUTHENTICATED = "Unauthenticated"
+
+
+async def category_and_release(
+ session: web.Committer | None, project_name: str, version_name: str
+) -> tuple[UserCategory, sql.Release, sql.Task | None]:
async with db.session() as data:
release = await data.release(
project_name=project_name,
version=version_name,
_committee=True,
- _project_release_policy=True,
).demand(base.ASFQuartException("Release does not exist",
errorcode=404))
+ if release.committee is None:
+ raise ValueError("Release has no committee")
+
+ latest_vote_task = await interaction.release_latest_vote_task(release,
data)
+ vote_initiator_uid: str | None = None
+ if latest_vote_task is not None:
+ vote_initiator_uid = latest_vote_task.task_args.get("initiator_id")
+
+ if session is None:
+ return UserCategory.UNAUTHENTICATED, release, latest_vote_task
+
+ is_pmc_member = (user.is_committee_member(release.committee, session.uid))
or (user.is_admin(session.uid))
+ is_release_manager = (vote_initiator_uid is not None) and (session.uid ==
vote_initiator_uid)
+
+ if is_pmc_member and is_release_manager:
+ user_category = UserCategory.PMC_MEMBER_RM
+ elif is_pmc_member:
+ user_category = UserCategory.PMC_MEMBER
+ elif is_release_manager:
+ user_category = UserCategory.COMMITTER_RM
+ else:
+ user_category = UserCategory.COMMITTER
+
+ return user_category, release, latest_vote_task
+
+
+async def render_options_page(
+ session: web.Committer | None,
+ release: sql.Release,
+ user_category: UserCategory,
+ latest_vote_task: sql.Task | None,
+) -> str:
+ """Render the vote options page for a release candidate."""
+ import atr.get.checks as checks
+
+ show_resolve_section = user_category in (
+ UserCategory.UNAUTHENTICATED,
+ UserCategory.COMMITTER_RM,
+ UserCategory.PMC_MEMBER_RM,
+ )
+
+ file_totals = await checks.get_file_totals(release, session)
+ archive_url = await _get_archive_url(release, session, latest_vote_task)
+
+ page = htm.Block()
+ _render_header(page, release, show_resolve_section)
+ _render_section_download(page, release, session, user_category)
+ _render_section_checks(page, release, file_totals)
+ await _render_section_vote(page, release, session, user_category,
archive_url)
+ if show_resolve_section:
+ _render_section_resolve(page, release, user_category)
+
+ return await template.blank(
+ f"Vote on {release.project.short_display_name} {release.version}",
+ content=page.collect(),
+ init_js=True,
+ )
+
+
[email protected]("/vote/<project_name>/<version_name>")
+async def selected(session: web.Committer | None, project_name: str,
version_name: str) -> web.WerkzeugResponse | str:
+ """Show voting options for a release candidate."""
+ user_category, release, latest_vote_task = await
category_and_release(session, project_name, version_name)
+
if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
if session is None:
raise base.ASFQuartException("Release is not a candidate",
errorcode=404)
return await mapping.release_as_redirect(session, release)
+ return await render_options_page(session, release, user_category,
latest_vote_task)
+
+
+def _download_browse(release: sql.Release) -> htm.Element:
+ browse_url = util.as_url(
+ download.path_empty,
+ project_name=release.project.name,
+ version_name=release.version,
+ )
+ return htm.div(".d-flex.align-items-center.gap-2")[
+ htpy.a(".btn.btn-outline-primary", href=browse_url)[
+ htpy.i(".bi.bi-folder2-open.me-1"),
+ "→ Browse files",
+ ],
+ ]
+
+
+def _download_curl(release: sql.Release) -> htm.Element:
+ app_host = config.get().APP_HOST
+ script_url = util.as_url(
+ download.sh_selected,
+ project_name=release.project.name,
+ version_name=release.version,
+ )
+ curl_command = f"curl -s https://{app_host}{script_url} | sh"
+
+ return htm.div(".mb-3")[
+ htm.div(".mb-2")[htm.strong["Use curl:"]],
+ htm.div(".input-group")[
+ htpy.span(
+ "#curl-command.form-control.font-monospace.bg-light",
+ )[curl_command],
+ htpy.button(
+ ".btn.btn-outline-secondary.atr-copy-btn",
+ type="button",
+ data_clipboard_target="#curl-command",
+ )[htpy.i(".bi.bi-clipboard"), " Copy"],
+ ],
+ htm.div(".form-text.text-muted")["This command downloads all release
files to the current directory.",],
+ ]
+
+
+def _download_rsync(release: sql.Release, session: web.Committer) ->
htm.Element:
+ server_domain = config.get().APP_HOST.split(":", 1)[0]
+ if not session.uid.isalnum():
+ raise ValueError("Invalid UID")
+
+ rsync_command = (
+ f"rsync -av -e 'ssh -p 2222'
{session.uid}@{server_domain}:/{release.project.name}/{release.version}/ ./"
+ )
+
+ return htm.div(".mb-3")[
+ htm.div(".mb-2")[htm.strong["Use rsync:"]],
+ htm.div(".input-group")[
+ htpy.span(
+ "#rsync-command.form-control.font-monospace.bg-light",
+ )[rsync_command],
+ htpy.button(
+ ".btn.btn-outline-secondary.atr-copy-btn",
+ type="button",
+ data_clipboard_target="#rsync-command",
+ )[htpy.i(".bi.bi-clipboard"), " Copy"],
+ ],
+ htm.div(".form-text.text-muted")[
+ "Requires SSH key configuration. ",
+ htpy.a(href=util.as_url(keys.keys))["Manage your SSH keys"],
+ ".",
+ ],
+ ]
+
+
+def _download_zip(release: sql.Release) -> htm.Element:
+ zip_url = util.as_url(
+ download.zip_selected,
+ project_name=release.project.name,
+ version_name=release.version,
+ )
+ return htm.div(".d-flex.align-items-center.gap-2")[
+ htpy.a(".btn.btn-primary", href=zip_url)[
+ htpy.i(".bi.bi-file-earmark-zip.me-1"),
+ "Download all (ZIP)",
+ ],
+ ]
+
+
+async def _get_archive_url(
+ release: sql.Release, session: web.Committer | None, latest_vote_task:
sql.Task | None
+) -> str | None:
+ if latest_vote_task is None:
+ return None
+
+ task_mid = interaction.task_mid_get(latest_vote_task)
+ if task_mid is None:
+ return None
+
+ task_recipient = interaction.task_recipient_get(latest_vote_task)
+ async with storage.write(session) as write:
+ wagp = write.as_general_public()
+ return await wagp.cache.get_message_archive_url(task_mid,
task_recipient)
+
+
+def _render_header(page: htm.Block, release: sql.Release,
show_resolve_section: bool) -> None:
+ shared.distribution.html_nav(
+ page,
+ back_url=util.as_url(root.index),
+ back_anchor="Select a release",
+ phase="VOTE",
+ )
+
+ page.h1[
+ "Vote on ",
+ htm.strong[release.project.short_display_name],
+ " ",
+ htm.em[release.version],
+ ]
+
if release.committee is None:
raise ValueError("Release has no committee")
- is_authenticated = session is not None
- is_committee_member = is_authenticated and (
- user.is_committee_member(release.committee, session.uid) or
user.is_admin(session.uid)
- )
- can_vote = is_committee_member
- can_resolve = is_committee_member
-
- latest_vote_task = await interaction.release_latest_vote_task(release)
- archive_url = None
- task_mid = None
-
- if latest_vote_task is not None:
- if util.is_dev_environment():
- log.warning("Setting vote task to completed in dev environment")
- latest_vote_task.status = sql.TaskStatus.COMPLETED
- latest_vote_task.result = results.VoteInitiate(
- kind="vote_initiate",
- message="Vote announcement email sent successfully",
- email_to="[email protected]",
- vote_end="2025-07-01 12:00:00",
- subject="Test vote",
- mid=interaction.TEST_MID,
- mail_send_warnings=[],
- )
-
- # Move task_mid_get here?
- task_mid = interaction.task_mid_get(latest_vote_task)
- task_recipient = interaction.task_recipient_get(latest_vote_task)
- async with storage.write(session) as write:
- wagp = write.as_general_public()
- archive_url = await wagp.cache.get_message_archive_url(task_mid,
task_recipient)
-
- resolve_form = None
- if can_resolve:
- # Special form for the [ Resolve vote ] button, to make it POST
- resolve_form = await forms.Submit.create_form()
- resolve_form.submit.label.text = "Resolve vote"
-
- cast_vote_form = None
- if can_vote:
+ page.p[
+ "The ",
+ htm.strong[release.committee.display_name],
+ " committee is currently voting on the release candidate for"
+ f" {release.project.display_name} {release.version}.",
+ ]
+
+ page.p["To participate in this vote, please select your next step:"]
+
+ steps = htm.Block(htpy.ol, classes=".atr-steps")
+ steps.li[htpy.a(".atr-step-link", href="#download")["Download the release
files"]]
+ steps.li[htpy.a(".atr-step-link", href="#checks")["Review file checks"]]
+ steps.li[htpy.a(".atr-step-link", href="#vote")["Cast your vote"]]
+ if show_resolve_section:
+ steps.li[htpy.a(".atr-step-link", href="#resolve")["Resolve the vote
(release managers only)"]]
+ page.append(steps.collect())
+
+
+def _render_section_checks(page: htm.Block, release: sql.Release, file_totals:
checks.FileStats) -> None:
+ import atr.get.checks as checks
+
+ page.h2("#checks")["2. Review file checks"]
+
+ page.p["ATR has checked this release candidate with the following
results:"]
+
+ summary = htm.Block(htm.div, classes=".card.mb-4")
+ summary.div(".card-header.bg-light")["Automated checks"]
+
+ body = htm.Block(htm.div, classes=".card-body")
+
+ pass_count = file_totals.file_pass_after
+ warn_count = file_totals.file_warn_after
+ err_count = file_totals.file_err_after
+
+ check_word = "check" if (pass_count == 1) else "checks"
+ warn_word = "warning" if (warn_count == 1) else "warnings"
+ err_word = "error" if (err_count == 1) else "errors"
+
+ checks_list = htm.Block(htm.div, classes=".d-flex.flex-wrap.gap-4.mb-3")
+ checks_list.span(".text-success")[
+ htpy.i(".bi.bi-check-circle-fill.me-2"),
+ f"{pass_count} {check_word} passed",
+ ]
+ if warn_count > 0:
+ checks_list.span(".text-warning")[
+ htpy.i(".bi.bi-exclamation-triangle-fill.me-2"),
+ f"{warn_count} {warn_word}",
+ ]
+ else:
+ checks_list.span(".text-muted")[
+ htpy.i(".bi.bi-exclamation-triangle.me-2"),
+ "0 warnings",
+ ]
+ if err_count > 0:
+ checks_list.span(".text-danger")[
+ htpy.i(".bi.bi-x-circle-fill.me-2"),
+ f"{err_count} {err_word}",
+ ]
+ else:
+ checks_list.span(".text-muted")[
+ htpy.i(".bi.bi-x-circle.me-2"),
+ "0 errors",
+ ]
+ body.append(checks_list.collect())
+
+ body.div[
+ htpy.a(
+ ".btn.btn-outline-primary",
+ href=util.as_url(checks.selected,
project_name=release.project.name, version_name=release.version),
+ )["→ View detailed results"],
+ ]
+
+ summary.append(body.collect())
+ page.append(summary.collect())
+
+
+def _render_section_download(
+ page: htm.Block, release: sql.Release, session: web.Committer | None,
user_category: UserCategory
+) -> None:
+ page.h2("#download")["1. Download the release files"]
+
+ page.p[
+ "Download the release files to verify signatures, licenses, and test
functionality before casting your vote."
+ ]
+
+ is_authenticated = user_category != UserCategory.UNAUTHENTICATED
+
+ page.div(".mb-2")[htm.strong["Use your browser:"]]
+ buttons_row = htm.Block(htm.div, classes=".d-flex.flex-wrap.gap-3.mb-3")
+ buttons_row.append(_download_browse(release))
+ if is_authenticated and (session is not None):
+ buttons_row.append(_download_zip(release))
+ page.append(buttons_row.collect())
+
+ if is_authenticated and (session is not None):
+ page.append(_download_rsync(release, session))
+
+ page.append(_download_curl(release))
+
+ if not is_authenticated:
+ page.div(".mb-2")[htm.strong["Use alternatives:"]]
+ redirect_url = util.as_url(selected,
project_name=release.project.name, version_name=release.version)
+ login_url = f"/auth?login={urllib.parse.quote(redirect_url, safe='')}"
+ page.div(".mt-3")[
+ htpy.a(".btn.btn-outline-secondary", href=login_url)[
+ htpy.i(".bi.bi-box-arrow-in-right.me-1"),
+ "Log in for ZIP and rsync downloads",
+ ],
+ ]
+
+
+def _render_section_resolve(page: htm.Block, release: sql.Release,
user_category: UserCategory) -> None:
+ page.h2("#resolve")["4. Resolve the vote (release managers only)"]
+
+ if user_category == UserCategory.UNAUTHENTICATED:
+ page.p["If you are the release manager, log in to access vote tallying
and resolution tools."]
+ redirect_url = util.as_url(selected,
project_name=release.project.name, version_name=release.version)
+ login_url = f"/auth?login={urllib.parse.quote(redirect_url, safe='')}"
+ page.div[
+ htpy.a(".btn.btn-outline-secondary", href=login_url)[
+ htpy.i(".bi.bi-box-arrow-in-right.me-1"),
+ "Log in as Release Manager",
+ ]
+ ]
+ else:
+ page.p["When the voting period concludes, use the resolution page to
tally votes and record the outcome."]
+
+ # POST form for resolve button
+ resolve_url = util.as_url(
+ post.resolve.selected,
+ project_name=release.project.name,
+ version_name=release.version,
+ )
+ page.form(".mb-0", method="post", action=resolve_url)[
+ form.csrf_input(),
+ htpy.input(type="hidden", name="variant", value="tabulate"),
+ htpy.button(".btn.btn-success", type="submit")[
+ htpy.i(".bi.bi-clipboard-check.me-1"),
+ "Resolve vote",
+ ],
+ ]
+
+
+async def _render_section_vote(
+ page: htm.Block,
+ release: sql.Release,
+ session: web.Committer | None,
+ user_category: UserCategory,
+ archive_url: str | None,
+) -> None:
+ page.h2("#vote")["3. Cast your vote"]
+
+ if release.committee is None:
+ raise ValueError("Release has no committee")
+
+ if user_category == UserCategory.UNAUTHENTICATED:
+ _render_vote_unauthenticated(page, release, archive_url)
+ else:
+ await _render_vote_authenticated(page, release, session,
user_category, archive_url)
+
+
+async def _render_vote_authenticated(
+ page: htm.Block,
+ release: sql.Release,
+ session: web.Committer | None,
+ user_category: UserCategory,
+ archive_url: str | None,
+) -> None:
+ if release.committee is None:
+ raise ValueError("Release has no committee")
+ if session is None:
+ raise ValueError("Session required for authenticated vote")
+
+ # Determine vote potency based on user category
+ # For podlings, incubator PMC membership grants binding status always
+ # This breaks the test route though
+ is_pmc_member = user_category in (UserCategory.PMC_MEMBER,
UserCategory.PMC_MEMBER_RM)
+
+ if release.committee.is_podling:
async with storage.write() as write:
try:
- if release.committee.is_podling:
- _wacm = write.as_committee_member("incubator")
- else:
- _wacm = write.as_committee_member(release.committee.name)
- potency = "Binding"
+ _wacm = write.as_committee_member("incubator")
+ is_binding = True
except storage.AccessError:
- potency = "Non-binding"
-
- vote_widget = htpy.div(class_="btn-group", role="group")[
- htpy.input(
- type="radio", class_="btn-check", name="decision",
id="decision_0", value="+1", autocomplete="off"
- ),
- htpy.label(class_="btn btn-outline-success",
for_="decision_0")[f"+1 ({potency})"],
- htpy.input(
- type="radio", class_="btn-check", name="decision",
id="decision_1", value="0", autocomplete="off"
- ),
- htpy.label(class_="btn btn-outline-secondary",
for_="decision_1")["0"],
- htpy.input(
- type="radio", class_="btn-check", name="decision",
id="decision_2", value="-1", autocomplete="off"
- ),
- htpy.label(class_="btn btn-outline-danger",
for_="decision_2")[f"-1 ({potency})"],
+ is_binding = False
+ binding_committee = "Incubator"
+ else:
+ is_binding = is_pmc_member
+ binding_committee = release.committee.display_name
+
+ potency = "Binding" if is_binding else "Non-binding"
+ if is_binding:
+ page.p[
+ f"As a member of the {binding_committee} committee, your vote is ",
+ htpy.strong["binding"],
+ ".",
+ ]
+ else:
+ page.p[
+ f"You are not a member of the {binding_committee} committee. ",
+ "Your vote will be recorded as ",
+ htpy.strong["non-binding"],
+ " but is still valued by the community.",
]
- cast_vote_form = form.render(
- model_cls=shared.vote.CastVoteForm,
- submit_label="Submit vote",
- form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
- custom={"decision": vote_widget},
- )
+ # Note about where vote goes, with link to thread if available
+ mailing_list = f"dev@{release.committee.name}.apache.org"
+ if archive_url:
+ page.p[
+ "Your vote will be sent to ",
+ htpy.code[mailing_list],
+ " (",
+ htpy.a(href=archive_url, target="_blank", rel="noopener")["view
thread"],
+ ").",
+ ]
+ else:
+ page.p["Your vote will be sent to ", htpy.code[mailing_list], "."]
- return await shared.check(
- session,
- release,
- task_mid=task_mid,
- form=cast_vote_form,
- resolve_form=resolve_form,
- archive_url=archive_url,
- vote_task=latest_vote_task,
- can_vote=can_vote,
- can_resolve=can_resolve,
+ # Build the vote widget
+ vote_widget = htpy.div(class_="btn-group", role="group")[
+ htpy.input(type="radio", class_="btn-check", name="decision",
id="decision_0", value="+1", autocomplete="off"),
+ htpy.label(class_="btn btn-outline-success", for_="decision_0")[f"+1
({potency})"],
+ htpy.input(type="radio", class_="btn-check", name="decision",
id="decision_1", value="0", autocomplete="off"),
+ htpy.label(class_="btn btn-outline-secondary", for_="decision_1")["0"],
+ htpy.input(type="radio", class_="btn-check", name="decision",
id="decision_2", value="-1", autocomplete="off"),
+ htpy.label(class_="btn btn-outline-danger", for_="decision_2")[f"-1
({potency})"],
+ ]
+
+ # Render the form
+ vote_action_url = util.as_url(
+ post.vote.selected_post,
+ project_name=release.project.name,
+ version_name=release.version,
+ )
+ cast_vote_form = form.render(
+ model_cls=shared.vote.CastVoteForm,
+ action=vote_action_url,
+ submit_label="Submit vote",
+ form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
+ custom={"decision": vote_widget},
)
+ page.append(cast_vote_form)
+
+
+def _render_vote_unauthenticated(page: htm.Block, release: sql.Release,
archive_url: str | None) -> None:
+ page.p["Once you have reviewed the release, you can cast your vote."]
+
+ redirect_url = util.as_url(selected, project_name=release.project.name,
version_name=release.version)
+ login_url = f"/auth?login={urllib.parse.quote(redirect_url, safe='')}"
+
+ # ASF Committers box
+ committer_box = htm.Block(htm.div, classes=".card.mb-3")
+ committer_box.div(".card-header.bg-light")[
+ htpy.i(".bi.bi-key-fill.me-2"),
+ "ASF Committers",
+ ]
+ committer_body = htm.Block(htm.div, classes=".card-body")
+ committer_body.p["Log in to vote directly through ATR. Your vote will be
recorded and sent to the mailing list."]
+ committer_body.div[
+ htpy.a(".btn.btn-outline-secondary", href=login_url)[
+ htpy.i(".bi.bi-box-arrow-in-right.me-1"),
+ "Log in to vote",
+ ],
+ ]
+ committer_box.append(committer_body.collect())
+ page.append(committer_box.collect())
+
+ # Everyone else box
+ email_box = htm.Block(htm.div, classes=".card.mb-3")
+ email_box.div(".card-header.bg-light")[
+ htpy.i(".bi.bi-envelope-fill.me-2"),
+ "Everyone else",
+ ]
+ email_body = htm.Block(htm.div, classes=".card-body")
+ email_body.p["Contributors and community members can vote by replying to
the vote thread on the mailing list."]
+ if archive_url:
+ email_body.div[
+ htpy.a(".btn.btn-outline-primary", href=archive_url,
target="_blank", rel="noopener")[
+ "View vote thread ",
+ htpy.i(".bi.bi-box-arrow-up-right"),
+ ],
+ ]
+ else:
+ committee_name = release.committee.name if release.committee else
"unknown"
+ email_body.p(".text-muted.mb-0")[
+ "The vote thread archive is not yet available. ",
+ f"Check the dev@{committee_name}.apache.org mailing list.",
+ ]
+ email_box.append(email_body.collect())
+ page.append(email_box.collect())
diff --git a/atr/htm.py b/atr/htm.py
index 24466f5..ef96399 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -273,7 +273,7 @@ class Block:
@property
def tr(self) -> BlockElementCallable:
- self.__check_parent("tr", {"tbody", "table"})
+ self.__check_parent("tr", {"tbody", "thead", "table"})
return BlockElementCallable(self, tr)
@property
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index e962a4f..67c673d 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -523,6 +523,17 @@ aside.sidebar nav a:hover {
white-space: pre-wrap;
}
+.atr-steps {
+ padding-left: 1.6rem;
+ font-size: 1.4rem;
+ font-weight: 500;
+ line-height: 1.6;
+}
+
+.atr-step-link {
+ font-weight: 500;
+}
+
.atr-nowrap {
white-space: nowrap;
}
diff --git a/atr/template.py b/atr/template.py
index 9916e88..d175a6d 100644
--- a/atr/template.py
+++ b/atr/template.py
@@ -28,8 +28,10 @@ import atr.htm as htm
render_async = quart.render_template
-async def blank(title: str, content: str | htm.Element, description: str |
None = None) -> str:
- return await render_sync("blank.html", title=title,
description=description or title, content=content)
+async def blank(title: str, content: str | htm.Element, description: str |
None = None, init_js: bool = False) -> str:
+ return await render_sync(
+ "blank.html", title=title, description=description or title,
content=content, init_js=init_js
+ )
async def render_sync(
diff --git a/atr/templates/blank.html b/atr/templates/blank.html
index b8d1536..2f6ca90 100644
--- a/atr/templates/blank.html
+++ b/atr/templates/blank.html
@@ -17,4 +17,7 @@
{% if javascripts %}
{% for js in javascripts %}<script src="{{- js -}}"></script>{% endfor %}
{% endif %}
+ {% if init_js %}<script>
+ init();
+</script>{% endif %}
{% endblock javascripts %}
diff --git a/atr/templates/check-selected-path-table.html
b/atr/templates/check-selected-path-table.html
index 42f95d7..c8020dc 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -1,6 +1,6 @@
<div class="table-responsive">
{# This table uses pairs of rows, so it must be manually striped #}
- <table class="table table-hover align-middle table-sm mb-0 border">
+ <table class="table align-middle table-sm mb-0 border">
<tbody>
{% for path in paths %}
{% set has_errors = info and (info.errors[path]|length > 0) %}
diff --git a/atr/templates/committee-view.html
b/atr/templates/committee-view.html
index 43cb28d..b03fd98 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -52,7 +52,7 @@
</div>
{% if committee.public_signing_keys %}
<div class="table-responsive mb-2">
- <table class="table border table-striped table-hover table-sm">
+ <table class="table border table-striped table-sm">
<thead>
<tr>
<th class="px-2" scope="col">Key ID</th>
diff --git a/atr/templates/includes/sidebar.html
b/atr/templates/includes/sidebar.html
index 7cbbc01..467f97b 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -18,13 +18,13 @@
<br />
<a href="#"
onclick="location.href='/auth?logout=/';"
- class="logout-link btn btn-sm btn-outline-secondary mt-2">Logout</a>
+ class="logout-link btn btn-sm btn-outline-secondary mt-2">Log
out</a>
</div>
{% else %}
<a href="#"
onclick="location.href='/auth?login=' + window.location.pathname;"
- class="login-link btn btn-sm btn-secondary">Login</a>
+ class="login-link btn btn-sm btn-secondary">Log in</a>
{% endif %}
</div>
<nav>
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]