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 73aa3e5 Make the form to upload a KEYS file more type safe
73aa3e5 is described below
commit 73aa3e52269953c192246ce109a95b927188c6df
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Nov 12 19:18:44 2025 +0000
Make the form to upload a KEYS file more type safe
---
atr/get/keys.py | 2 +-
atr/post/keys.py | 62 ++++++-
atr/shared/keys.py | 387 ++++++++++++++++++++++++++---------------
atr/templates/keys-upload.html | 252 ---------------------------
4 files changed, 306 insertions(+), 397 deletions(-)
diff --git a/atr/get/keys.py b/atr/get/keys.py
index 78487ef..da1f174 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -282,7 +282,7 @@ async def ssh_add(session: web.Committer) -> str:
@get.committer("/keys/upload")
async def upload(session: web.Committer) -> str:
"""Upload a KEYS file containing multiple OpenPGP keys."""
- return await shared.keys.upload(session)
+ return await shared.keys.render_upload_page()
def _committee_keys(page: htm.Block, user_committees_with_keys:
list[sql.Committee]) -> None:
diff --git a/atr/post/keys.py b/atr/post/keys.py
index cf60b33..4a0d916 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -16,6 +16,10 @@
# under the License.
+import asyncio
+
+import aiohttp
+import asfquart.base as base
import quart
import atr.blueprints.post as post
@@ -134,18 +138,17 @@ async def details(
@post.committer("/keys/import/<project_name>/<version_name>")
[email protected]()
async def import_selected_revision(
session: web.Committer, project_name: str, version_name: str
) -> web.WerkzeugResponse:
- 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,"
+ 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}"
+ message += f", failed to upload {outcomes.error_count} keys for
{wacm.committee_name}"
return await session.redirect(
get.compose.selected,
success=message,
@@ -174,9 +177,43 @@ async def ssh_add(session: web.Committer,
add_ssh_key_form: shared.keys.AddSSHKe
@post.committer("/keys/upload")
-async def upload(session: web.Committer) -> str:
[email protected](shared.keys.UploadKeysForm)
+async def upload(session: web.Committer, upload_form:
shared.keys.UploadKeysForm) -> str:
"""Upload a KEYS file containing multiple OpenPGP keys."""
- return await shared.keys.upload(session)
+ keys_text = ""
+
+ try:
+ if upload_form.key:
+ keys_content = await asyncio.to_thread(upload_form.key.read)
+ keys_text = keys_content.decode("utf-8", errors="replace")
+ elif upload_form.keys_url:
+ keys_text = await _fetch_keys_from_url(str(upload_form.keys_url))
+
+ if not keys_text:
+ await quart.flash("No KEYS data found", "error")
+ return await shared.keys.render_upload_page(error=True)
+
+ selected_committee = upload_form.selected_committee
+
+ async with storage.write() as write:
+ wacp = write.as_committee_participant(selected_committee)
+ outcomes = await wacp.keys.ensure_associated(keys_text)
+
+ success_count = outcomes.result_count
+ error_count = outcomes.error_count
+ total_count = success_count + error_count
+
+ message = f"Processed {total_count} keys: {success_count} successful"
+ if error_count > 0:
+ message += f", {error_count} failed"
+
+ await quart.flash(message, "success" if (success_count > 0) else
"error")
+
+ return await shared.keys.render_upload_page(results=outcomes,
submitted_committees=[selected_committee])
+ except Exception as e:
+ log.exception("Error uploading KEYS file:")
+ await quart.flash(f"Error processing KEYS file: {e!s}", "error")
+ return await shared.keys.render_upload_page(error=True)
async def _delete_openpgp_key(
@@ -227,3 +264,16 @@ async def _update_committee_keys(
await quart.flash(f"Error regenerating the KEYS file for the
{committee_name} committee.", "error")
return await session.redirect(get.keys.keys)
+
+
+async def _fetch_keys_from_url(keys_url: str) -> str:
+ """Fetch KEYS file content from a URL."""
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(keys_url, allow_redirects=True) as response:
+ response.raise_for_status()
+ return await response.text()
+ except aiohttp.ClientResponseError as e:
+ raise base.ASFQuartException(f"Unable to fetch keys from remote
server: {e.status} {e.message}", errorcode=502)
+ except aiohttp.ClientError as e:
+ raise base.ASFQuartException(f"Network error while fetching keys:
{e}", errorcode=503)
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index e0c4aa3..65889a2 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -17,27 +17,19 @@
"""keys.py"""
-import asyncio
-from collections.abc import Awaitable, Callable, Sequence
from typing import Annotated, Literal
-import aiohttp
-import asfquart.base as base
+import htpy
+import markupsafe
import pydantic
-import quart
-import werkzeug.datastructures as datastructures
-import wtforms
import atr.form as form
-import atr.forms as forms
-import atr.models.sql as sql
+import atr.htm as htm
import atr.shared as shared
import atr.storage as storage
-import atr.storage.outcome as outcome
import atr.storage.types as types
import atr.template as template
import atr.util as util
-import atr.web as web
type DELETE_OPENPGP_KEY = Literal["delete_openpgp_key"]
type DELETE_SSH_KEY = Literal["delete_ssh_key"]
@@ -99,147 +91,266 @@ class UpdateKeyCommitteesForm(form.Form):
)
-class UploadKeyFormBase(forms.Typed):
- key = forms.file(
+class UploadKeysForm(form.Form):
+ key: form.File = form.label(
"KEYS file",
- optional=True,
- description=(
- "Upload a KEYS file containing multiple PGP public keys."
- " The file should contain keys in ASCII-armored format, starting
with"
- ' "-----BEGIN PGP PUBLIC KEY BLOCK-----".'
- ),
+ "Upload a KEYS file containing multiple PGP public keys."
+ " The file should contain keys in ASCII-armored format, starting with"
+ ' "-----BEGIN PGP PUBLIC KEY BLOCK-----".',
+ widget=form.Widget.CUSTOM,
)
- keys_url = forms.url(
+ keys_url: form.OptionalURL = form.label(
"KEYS file URL",
- optional=True,
- placeholder="Enter URL to KEYS file",
- description="Enter a URL to a KEYS file. This will be fetched by the
server.",
+ "Enter a URL to a KEYS file. This will be fetched by the server.",
+ widget=form.Widget.CUSTOM,
)
-
- selected_committee = forms.radio(
+ selected_committee: str = form.label(
"Associate keys with committee",
- description=(
- "Select the committee with which to associate these keys. You must
be a member of the selected committee."
- ),
+ "Select the committee with which to associate these keys.",
+ widget=form.Widget.RADIO,
)
- submit = forms.submit("Upload KEYS file")
+ @pydantic.model_validator(mode="after")
+ def validate_key_source(self) -> "UploadKeysForm":
+ if (not self.key) and (not self.keys_url):
+ raise ValueError("Either a file or a URL is required")
+ if self.key and self.keys_url:
+ raise ValueError("Provide either a file or a URL, not both")
+ return self
- async def validate(self, extra_validators: dict | None = None) -> bool:
- """Ensure that either a file is uploaded or a URL is provided, but not
both."""
- if not await super().validate(extra_validators):
- return False
- if not self.key.data and not self.keys_url.data:
- msg = "Either a file or a URL is required."
- if self.key.errors and isinstance(self.key.errors, list):
- self.key.errors.append(msg)
- else:
- self.key.errors = [msg]
- return False
- if self.key.data and self.keys_url.data:
- msg = "Provide either a file or a URL, not both."
- if self.key.errors and isinstance(self.key.errors, list):
- self.key.errors.append(msg)
+
+def _get_results_table_css() -> htm.Element:
+ return htm.style[
+ markupsafe.Markup(
+ """
+ .page-rotated-header {
+ height: 180px;
+ position: relative;
+ vertical-align: bottom;
+ padding-bottom: 5px;
+ width: 40px;
+ }
+ .page-rotated-header > div {
+ transform-origin: bottom left;
+ transform: translateX(25px) rotate(-90deg);
+ position: absolute;
+ bottom: 12px;
+ left: 6px;
+ white-space: nowrap;
+ text-align: left;
+ }
+ .table th, .table td {
+ text-align: center;
+ vertical-align: middle;
+ }
+ .table td.page-key-details {
+ text-align: left;
+ font-family: ui-monospace, "SFMono-Regular", "Menlo", "Monaco",
"Consolas", monospace;
+ font-size: 0.9em;
+ word-break: break-all;
+ }
+ .page-status-cell-new {
+ background-color: #197a4e !important;
+ }
+ .page-status-cell-existing {
+ background-color: #868686 !important;
+ }
+ .page-status-cell-unknown {
+ background-color: #ffecb5 !important;
+ }
+ .page-status-cell-error {
+ background-color: #dc3545 !important;
+ }
+ .page-status-square {
+ display: inline-block;
+ width: 36px;
+ height: 36px;
+ vertical-align: middle;
+ }
+ .page-table-bordered th, .page-table-bordered td {
+ border: 1px solid #dee2e6;
+ }
+ tbody tr {
+ height: 40px;
+ }
+ """
+ )
+ ]
+
+
+def _render_results_table(
+ page: htm.Block, results: storage.outcome.List, submitted_committees:
list[str], committee_map: dict[str, str]
+) -> None:
+ """Render the KEYS processing results table."""
+ page.h2["KEYS processing results"]
+ page.p[
+ "The following keys were found in your KEYS file and processed against
the selected committees. "
+ "Green squares indicate that a key was added, grey squares indicate
that a key already existed, "
+ "and red squares indicate an error."
+ ]
+
+ thead = htm.Block(htm.thead)
+ header_row = htm.Block(htm.tr)
+ header_row.th(scope="col")["Key ID"]
+ header_row.th(scope="col")["User ID"]
+ for committee_name in submitted_committees:
+ header_row.th(".page-rotated-header",
scope="col")[htm.div[committee_map.get(committee_name, committee_name)]]
+ thead.append(header_row.collect())
+
+ tbody = htm.Block(htm.tbody)
+ for outcome in results.outcomes():
+ if outcome.ok:
+ key_obj = outcome.result_or_none()
+ fingerprint = key_obj.key_model.fingerprint if key_obj else
"UNKNOWN"
+ email_addr = key_obj.key_model.primary_declared_uid if key_obj
else ""
+ # Check whether the LINKED flag is set
+ added_flag = bool(key_obj.status & types.KeyStatus.LINKED) if
key_obj else False
+ error_flag = False
+ else:
+ err = outcome.error_or_none()
+ key_obj = getattr(err, "key", None) if err else None
+ fingerprint = key_obj.key_model.fingerprint if key_obj else
"UNKNOWN"
+ email_addr = key_obj.key_model.primary_declared_uid if key_obj
else ""
+ added_flag = False
+ error_flag = True
+
+ row = htm.Block(htm.tr)
+ row.td(".page-key-details.px-2")[htm.code[fingerprint[-16:].upper()]]
+ row.td(".page-key-details.px-2")[email_addr or ""]
+
+ for committee_name in submitted_committees:
+ if error_flag:
+ cell_class = "page-status-cell-error"
+ title_text = "Error processing key"
+ elif added_flag:
+ cell_class = "page-status-cell-new"
+ title_text = "Newly linked"
else:
- self.key.errors = [msg]
- return False
- return True
+ cell_class = "page-status-cell-existing"
+ title_text = "Already linked"
+ row.td(".text-center.align-middle.page-status-cell-container")[
+ htm.span(f".page-status-square.{cell_class}", title=title_text)
+ ]
-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()
+ tbody.append(row.collect())
- # TODO: Migrate to the forms interface
- class UploadKeyForm(UploadKeyFormBase):
- selected_committee = wtforms.SelectField(
- "Associate keys with committee",
- choices=[
- (c.name, c.display_name)
- for c in participant_of_committees
- if (not util.committee_is_standing(c.name)) or (c.name ==
"tooling")
- ],
- coerce=str,
- option_widget=wtforms.widgets.RadioInput(),
- widget=wtforms.widgets.ListWidget(prefix_label=False),
- validators=[wtforms.validators.InputRequired("You must select at
least one committee")],
- description="Select the committee with which to associate these
keys.",
- )
+ table_div = htm.div(".table-responsive")[
+
htm.table(".table.table-striped.page-table-bordered.table-sm.mt-3")[thead.collect(),
tbody.collect()]
+ ]
+ page.append(table_div)
- form = await UploadKeyForm.create_form()
- results: outcome.List[types.Key] | None = None
-
- async def render(
- error: str | None = None,
- submitted_committees: list[str] | None = None,
- all_user_committees: Sequence[sql.Committee] | None = None,
- ) -> str:
- # For easier happy pathing
- if error is not None:
- await quart.flash(error, "error")
-
- # Determine which committee list to use
- current_committees = all_user_committees if (all_user_committees is
not None) else participant_of_committees
- committee_map = {c.name: c.display_name for c in current_committees}
-
- return await template.render(
- "keys-upload.html",
- asf_id=session.uid,
- user_committees=current_committees,
- committee_map=committee_map,
- form=form,
- results=results,
- algorithms=shared.algorithms,
- submitted_committees=submitted_committees,
- )
+ processing_errors = [o for o in results.outcomes() if not o.ok]
+ if processing_errors:
+ page.h3(".text-danger.mt-4")["Processing errors"]
+ for outcome in processing_errors:
+ err = outcome.error_or_none()
+ page.div(".alert.alert-danger.p-2.mb-3")[str(err)]
- if await form.validate_on_submit():
- keys_text = ""
- if form.key.data:
- key_file = form.key.data
- if not isinstance(key_file, datastructures.FileStorage):
- return await render(error="Invalid file upload")
- keys_content = await asyncio.to_thread(key_file.read)
- keys_text = keys_content.decode("utf-8", errors="replace")
- elif form.keys_url.data:
- keys_text = await _get_keys_text(form.keys_url.data, render)
-
- if not keys_text:
- return await render(error="No KEYS data found.")
-
- # Get selected committee list from the form
- selected_committee = form.selected_committee.data
- if not selected_committee:
- return await render(error="You must select at least one committee")
-
- async with storage.write() as write:
- wacp = write.as_committee_participant(selected_committee)
- outcomes = await wacp.keys.ensure_associated(keys_text)
- results = outcomes
- success_count = outcomes.result_count
- error_count = outcomes.error_count
- total_count = success_count + error_count
-
- await quart.flash(
- f"Processed {total_count} keys: {success_count} successful,
{error_count} failed",
- "success" if success_count > 0 else "error",
- )
- return await render(
- submitted_committees=[selected_committee],
- all_user_committees=participant_of_committees,
- )
- return await render()
+async def render_upload_page(
+ results: storage.outcome.List | None = None,
+ submitted_committees: list[str] | None = None,
+ error: bool = False,
+) -> str:
+ """Render the upload page with optional results."""
+ import atr.get as get
+ import atr.post as post
+ async with storage.write() as write:
+ participant_of_committees = await write.participant_of_committees()
-async def _get_keys_text(keys_url: str, render: Callable[[str],
Awaitable[str]]) -> str:
- try:
- async with aiohttp.ClientSession() as session:
- async with session.get(keys_url, allow_redirects=True) as response:
- response.raise_for_status()
- return await response.text()
- except aiohttp.ClientResponseError as e:
- raise base.ASFQuartException(f"Unable to fetch keys from remote
server: {e.status} {e.message}", errorcode=502)
- except aiohttp.ClientError as e:
- raise base.ASFQuartException(f"Network error while fetching keys:
{e}", errorcode=503)
+ eligible_committees = [
+ c for c in participant_of_committees if (not
util.committee_is_standing(c.name)) or (c.name == "tooling")
+ ]
+
+ committee_choices = [(c.name, c.display_name) for c in eligible_committees]
+ committee_map = {c.name: c.display_name for c in eligible_committees}
+
+ page = htm.Block()
+ page.p[htm.a(".atr-back-link", href=util.as_url(get.keys.keys))["← Back to
Manage keys"]]
+ page.h1["Upload a KEYS file"]
+ page.p["Upload a KEYS file containing multiple OpenPGP public signing
keys."]
+
+ if results and submitted_committees:
+ page.append(_get_results_table_css())
+ _render_results_table(page, results, submitted_committees,
committee_map)
+
+ custom_tabs_widget = _render_upload_tabs()
+
+ form.render_block(
+ page,
+ model_cls=shared.keys.UploadKeysForm,
+ action=util.as_url(post.keys.upload),
+ submit_label="Upload KEYS file",
+ cancel_url=util.as_url(get.keys.keys),
+ defaults={"selected_committee": committee_choices},
+ custom={"key": custom_tabs_widget},
+ skip=["keys_url"],
+ border=True,
+ wider_widgets=True,
+ )
+
+ return await template.blank(
+ "Upload a KEYS file",
+ content=page.collect(),
+ description="Upload a KEYS file containing multiple OpenPGP public
signing keys.",
+ )
+
+
+def _render_upload_tabs() -> htm.Element:
+ """Render the tabbed interface for file upload or URL input."""
+ tabs_ul = htm.ul(".nav.nav-tabs", id="keysUploadTab", role="tablist")[
+ htm.li(".nav-item", role="presentation")[
+ htpy.button(
+ class_="nav-link active",
+ id="file-upload-tab",
+ data_bs_toggle="tab",
+ data_bs_target="#file-upload-pane",
+ type="button",
+ role="tab",
+ aria_controls="file-upload-pane",
+ aria_selected="true",
+ )["Upload from file"]
+ ],
+ htm.li(".nav-item", role="presentation")[
+ htpy.button(
+ class_="nav-link",
+ id="url-upload-tab",
+ data_bs_toggle="tab",
+ data_bs_target="#url-upload-pane",
+ type="button",
+ role="tab",
+ aria_controls="url-upload-pane",
+ aria_selected="false",
+ )["Upload from URL"]
+ ],
+ ]
+
+ file_pane = htm.div(".tab-pane.fade.show.active", id="file-upload-pane",
role="tabpanel")[
+ htm.div(".pt-3")[
+ htpy.input(class_="form-control", id="key", name="key",
type="file"),
+ htm.div(".form-text.text-muted.mt-2")[
+ "Upload a KEYS file containing multiple PGP public keys. The
file should contain keys in "
+ 'ASCII-armored format, starting with "-----BEGIN PGP PUBLIC
KEY BLOCK-----".'
+ ],
+ ]
+ ]
+
+ url_pane = htm.div(".tab-pane.fade", id="url-upload-pane",
role="tabpanel")[
+ htm.div(".pt-3")[
+ htpy.input(
+ class_="form-control",
+ id="keys_url",
+ name="keys_url",
+ placeholder="Enter URL to KEYS file",
+ type="url",
+ value="",
+ ),
+ htm.div(".form-text.text-muted.mt-2")["Enter a URL to a KEYS file.
This will be fetched by the server."],
+ ]
+ ]
+
+ tab_content = htm.div(".tab-content",
id="keysUploadTabContent")[file_pane, url_pane]
+
+ return htm.div[tabs_ul, tab_content]
diff --git a/atr/templates/keys-upload.html b/atr/templates/keys-upload.html
deleted file mode 100644
index 36790bc..0000000
--- a/atr/templates/keys-upload.html
+++ /dev/null
@@ -1,252 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
- Upload a KEYS file ~ ATR
-{% endblock title %}
-
-{% block description %}
- Upload a KEYS file containing multiple OpenPGP public signing keys.
-{% endblock description %}
-
-{% block stylesheets %}
- {{ super() }}
- <style>
- .page-rotated-header {
- height: 180px;
- position: relative;
- vertical-align: bottom;
- padding-bottom: 5px;
- width: 40px;
- }
-
- .page-rotated-header>div {
- transform-origin: bottom left;
- transform: translateX(25px) rotate(-90deg);
- position: absolute;
- bottom: 12px;
- left: 6px;
- white-space: nowrap;
- text-align: left;
- }
-
- .table th,
- .table td {
- text-align: center;
- vertical-align: middle;
- }
-
- .table td.page-key-details {
- text-align: left;
- font-family: ui-monospace, "SFMono-Regular", "Menlo", "Monaco",
"Consolas", monospace;
- font-size: 0.9em;
- word-break: break-all;
- }
-
- .page-status-cell-new {
- background-color: #197a4e !important;
- }
-
- .page-status-cell-existing {
- background-color: #868686 !important;
- }
-
- .page-status-cell-unknown {
- background-color: #ffecb5 !important;
- }
-
- .page-status-cell-error {
- background-color: #dc3545 !important;
- }
-
- .page-status-square {
- display: inline-block;
- width: 36px;
- height: 36px;
- vertical-align: middle;
- }
-
- .page-table-bordered th,
- .page-table-bordered td {
- border: 1px solid #dee2e6;
- }
-
- tbody tr {
- height: 40px;
- }
- </style>
-{% endblock stylesheets %}
-
-{% block content %}
- <p>
- <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to
Manage keys</a>
- </p>
-
- <h1>Upload a KEYS file</h1>
- <p>Upload a KEYS file containing multiple OpenPGP public signing keys.</p>
-
- {{ forms.errors_summary(form) }}
-
- {% if results and submitted_committees %}
- <h2>KEYS processing results</h2>
- <p>
- The following keys were found in your KEYS file and processed against
the selected committees. Green squares indicate that a key was added, grey
squares indicate that a key already existed, and red squares indicate an error.
- </p>
- <div class="table-responsive">
- <table class="table table-striped page-table-bordered table-sm mt-3">
- <thead>
- <tr>
- <th scope="col">Key ID</th>
- <th scope="col">User ID</th>
- {% for committee_name in submitted_committees %}
- <th scope="col" class="page-rotated-header">
- <div>{{ committee_map.get(committee_name, committee_name)
}}</div>
- </th>
- {% endfor %}
- </tr>
- </thead>
- <tbody>
- {% for outcome in results.outcomes() %}
- {% if outcome.ok %}
- {% set key_obj = outcome.result_or_none() %}
- {% set fingerprint = key_obj.key_model.fingerprint %}
- {% set email_addr = key_obj.key_model.primary_declared_uid or ""
%}
- {% set added_flag = key_obj.status.value > 0 %}
- {% set error_flag = False %}
- {% else %}
- {% set err = outcome.exception_or_none() %}
- {% set key_obj = err.key if (err is not none and err.key is
defined) else None %}
- {% set fingerprint = key_obj.key_model.fingerprint if key_obj is
not none else "UNKNOWN" %}
- {% set email_addr = key_obj.key_model.primary_declared_uid if
key_obj is not none else "" %}
- {% set added_flag = False %}
- {% set error_flag = True %}
- {% endif %}
- <tr>
- <td class="page-key-details px-2">
- <code>{{ fingerprint[-16:]|upper }}</code>
- </td>
- <td class="page-key-details px-2">{{ email_addr }}</td>
- {% for committee_name in submitted_committees %}
- {% set cell_class = 'page-status-cell-error' if error_flag
- else 'page-status-cell-new' if added_flag
- else 'page-status-cell-existing' %}
- {% set title_text = 'Error processing key' if error_flag
- else 'Newly linked' if added_flag
- else 'Already linked' %}
- <td class="text-center align-middle
page-status-cell-container">
- <span class="page-status-square {{ cell_class }}" title="{{
title_text }}"></span>
- </td>
- {% endfor %}
- </tr>
- {% endfor %}
- </tbody>
- </table>
- </div>
-
- {% set processing_errors = results.outcomes() | selectattr('ok',
'equalto', False) | list %}
- {% if processing_errors %}
- <h3 class="text-danger mt-4">Processing errors</h3>
- {% for outcome in processing_errors %}
- {% set err = outcome.exception_or_none() %}
- <div class="alert alert-danger p-2 mb-3">{{ err }}</div>
- {% endfor %}
- {% endif %}
-
- {% endif %}
-
- <form method="post"
- class="atr-canary py-4 px-5"
- enctype="multipart/form-data"
- novalidate>
- {{ form.hidden_tag() }}
-
- <div class="mb-4">
- <div class="row mb-3 pb-3 border-bottom">
- {{ forms.label(form.key, col="md2") }}
- <div class="col-md-9">
- <ul class="nav nav-tabs" id="keysUploadTab" role="tablist">
- <li class="nav-item" role="presentation">
- <button class="nav-link active"
- id="file-upload-tab"
- data-bs-toggle="tab"
- data-bs-target="#file-upload-pane"
- type="button"
- role="tab"
- aria-controls="file-upload-pane"
- aria-selected="true">Upload from file</button>
- </li>
- <li class="nav-item" role="presentation">
- <button class="nav-link"
- id="url-upload-tab"
- data-bs-toggle="tab"
- data-bs-target="#url-upload-pane"
- type="button"
- role="tab"
- aria-controls="url-upload-pane"
- aria-selected="false">Upload from URL</button>
- </li>
- </ul>
- <div class="tab-content" id="keysUploadTabContent">
- <div class="tab-pane fade show active"
- id="file-upload-pane"
- role="tabpanel"
- aria-labelledby="file-upload-tab">
- <div class="pt-3">
- {{ forms.widget(form.key, id=form.key.id) }}
- {{ forms.errors(form.key, classes="invalid-feedback d-block")
}}
- {{ forms.description(form.key, classes="form-text text-muted
mt-2") }}
- </div>
- </div>
- <div class="tab-pane fade"
- id="url-upload-pane"
- role="tabpanel"
- aria-labelledby="url-upload-tab">
- <div class="pt-3">
- {{ forms.widget(form.keys_url, classes="form-control") }}
- {{ forms.errors(form.keys_url, classes="invalid-feedback
d-block") }}
- {{ forms.description(form.keys_url, classes="form-text
text-muted mt-2") }}
- </div>
- </div>
- </div>
- </div>
- </div>
-
- {% if user_committees %}
- <div class="row mb-3 pb-3 border-bottom">
- {{ forms.label(form.selected_committee, col="md2") }}
- <div class="col-md-9">
- <div class="row">
- {% for subfield in form.selected_committee %}
- <div class="col-sm-12 col-md-6 col-lg-4">
- <div class="form-check mb-2">
- {{ forms.widget(subfield, classes="form-check-input") }}
- {{ forms.label(subfield, classes="form-check-label") }}
- </div>
- </div>
- {% else %}
- <p class="text-muted fst-italic">No committees available for
association.</p>
- {% endfor %}
- </div>
- {{ forms.errors(form.selected_committee, classes="invalid-feedback
d-block") }}
- {{ forms.description(form.selected_committee, classes="form-text
text-muted mt-2") }}
- </div>
- </div>
- {% else %}
- <div class="row mb-3 pb-3 border-bottom">
- <div class="col-md-9 offset-md-2">
- <p class="text-danger">You must be a member of at least one
committee to add signing keys.</p>
- </div>
- </div>
- {% endif %}
- </div>
-
- <div class="mt-4 col-md-9 offset-md-2">
- {{ form.submit(class_="btn btn-primary") }}
- <a href="{{ as_url(get.keys.keys) }}"
- class="btn btn-link text-secondary">Cancel</a>
- </div>
- </form>
-{% endblock content %}
-
-{% block javascripts %}
- {{ super() }}
-{% endblock javascripts %}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]