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]

Reply via email to