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 ef192fc  Make the form to update key committees more type safe
ef192fc is described below

commit ef192fcc62bdc637f45bd325c89cd08b2aa6a0b6
Author: Sean B. Palmer <[email protected]>
AuthorDate: Wed Nov 12 16:17:48 2025 +0000

    Make the form to update key committees more type safe
---
 atr/form.py                     |   8 +-
 atr/get/keys.py                 | 164 +++++++++++++++++++++++++++++++++++++++-
 atr/htm.py                      |   7 +-
 atr/post/keys.py                | 141 +++++++++++++++++++++-------------
 atr/shared/keys.py              | 103 +------------------------
 atr/templates/keys-details.html | 108 --------------------------
 6 files changed, 267 insertions(+), 264 deletions(-)

diff --git a/atr/form.py b/atr/form.py
index 1f0677c..d7ed67b 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -69,6 +69,11 @@ class Widget(enum.Enum):
     URL = "url"
 
 
+def csrf_input() -> htm.VoidElement:
+    csrf_token = utils.generate_csrf()
+    return htpy.input(type="hidden", name="csrf_token", value=csrf_token)
+
+
 def flash_error_data(
     form_cls: type[Form] | TypeAliasType, errors: 
list[pydantic_core.ErrorDetails], form_data: dict[str, Any]
 ) -> dict[str, Any]:
@@ -261,8 +266,7 @@ def render(  # noqa: C901
     field_rows: list[htm.Element] = []
     hidden_fields: list[htm.Element | htm.VoidElement | markupsafe.Markup] = []
 
-    csrf_token = utils.generate_csrf()
-    hidden_fields.append(htpy.input(type="hidden", name="csrf_token", 
value=csrf_token))
+    hidden_fields.append(csrf_input())
 
     for field_name, field_info in model_cls.model_fields.items():
         if field_name == "csrf_token":
diff --git a/atr/get/keys.py b/atr/get/keys.py
index 8826785..78487ef 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -16,7 +16,11 @@
 # under the License.
 
 
+import datetime
+
+import htpy
 import markupsafe
+import quart
 
 import atr.blueprints.get as get
 import atr.db as db
@@ -27,6 +31,7 @@ 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
 
@@ -93,9 +98,109 @@ async def add(session: web.Committer) -> str:
 
 
 @get.committer("/keys/details/<fingerprint>")
-async def details(session: web.Committer, fingerprint: str) -> str | 
web.WerkzeugResponse:
+async def details(session: web.Committer, fingerprint: str) -> str:
     """Display details for a specific OpenPGP key."""
-    return await shared.keys.details(session, fingerprint)
+    fingerprint = fingerprint.lower()
+    async with db.session() as data:
+        key, is_owner = await _key_and_is_owner(data, session, fingerprint)
+        user_committees = []
+        if is_owner:
+            project_list = session.committees + session.projects
+            user_committees = await data.committee(name_in=project_list).all()
+
+    if isinstance(key.ascii_armored_key, bytes):
+        key.ascii_armored_key = key.ascii_armored_key.decode("utf-8", 
errors="replace")
+
+    page = htm.Block()
+    page.p[htm.a(".atr-back-link", href=util.as_url(keys))["← Back to Manage 
keys"]]
+    page.h1["OpenPGP key details"]
+
+    tbody = htm.Block(htm.tbody)
+
+    def _add_row(th: str, td: str | htm.Element) -> None:
+        tbody.append(htm.tr[htm.th(".p-2.text-dark")[th], 
htm.td(".text-break.align-middle")[td]])
+
+    _add_row("Fingerprint", key.fingerprint.upper())
+
+    algorithm_name = shared.algorithms[key.algorithm]
+    _add_row("Type", f"{algorithm_name} ({key.length} bits)")
+
+    _add_row("Created", key.created.strftime("%Y-%m-%d %H:%M:%S"))
+
+    latest_sig = key.latest_self_signature.strftime("%Y-%m-%d %H:%M:%S") if 
key.latest_self_signature else "Never"
+    _add_row("Latest self signature", latest_sig)
+
+    if key.expires:
+        now = datetime.datetime.now(datetime.UTC)
+        days_until_expiry = (key.expires - now).days
+        expires_str = key.expires.strftime("%Y-%m-%d %H:%M:%S")
+        if days_until_expiry < 0:
+            expires_content = htm.span(".text-danger.fw-bold")[
+                expires_str,
+                " ",
+                htm.span(".badge.bg-danger.text-white.ms-2")["Expired"],
+            ]
+        elif days_until_expiry <= 30:
+            expires_content = htm.span(".text-warning.fw-bold")[
+                expires_str,
+                " ",
+                htm.span(".badge.bg-warning.text-dark.ms-2")[f"Expires in 
{days_until_expiry} days"],
+            ]
+        else:
+            expires_content = expires_str
+    else:
+        expires_content = "Never"
+    _add_row("Expires", expires_content)
+
+    _add_row("Primary UID", key.primary_declared_uid or "-")
+    secondary_uids = ", ".join(key.secondary_declared_uids) if 
key.secondary_declared_uids else "-"
+
+    _add_row("Secondary UIDs", secondary_uids)
+
+    _add_row("Apache UID", key.apache_uid or "-")
+
+    pmc_div = htm.Block(htm.div, classes=".text-break.pt-2")
+    if is_owner:
+        committee_choices = [(c.name, c.display_name or c.name) for c in 
user_committees]
+        current_committee_names = [c.name for c in key.committees]
+
+        # form.render_block(
+        #     pmc_div,
+        #     model_cls=shared.keys.UpdateKeyCommitteesForm,
+        #     action=util.as_url(post.keys.details, fingerprint=fingerprint),
+        #     form_classes=".mb-4.d-inline-block",
+        #     submit_label="Update associations",
+        #     submit_classes="btn-primary btn-sm",
+        #     defaults={"selected_committees": committee_choices},
+        #     custom={"selected_committees": 
_render_committee_checkboxes(committee_choices, current_committee_names)},
+        # )
+        checkboxes = _render_committee_checkboxes(committee_choices, 
current_committee_names)
+        pmc_div.form(
+            method="post",
+            action=util.as_url(post.keys.details, fingerprint=fingerprint),
+        )[
+            form.csrf_input(),
+            checkboxes,
+            htm.div(".mt-3")[htpy.button(".btn.btn-primary.btn-sm", 
type="submit")["Update associations"]],
+        ]
+    else:
+        if key.committees:
+            committee_names = ", ".join([c.name for c in key.committees])
+            pmc_div.text(committee_names)
+        else:
+            pmc_div.text("No PMCs associated")
+    _add_row("Associated PMCs", pmc_div.collect())
+
+    
page.table(".mb-0.table.border.border-2.table-striped.table-sm")[tbody.collect()]
+
+    page.h2["ASCII armored key"]
+    page.pre(".mt-3.border.border-2.p-3")[key.ascii_armored_key]
+
+    return await template.blank(
+        "OpenPGP key details",
+        content=page.collect(),
+        description="View details for a specific OpenPGP public signing key.",
+    )
 
 
 @get.committer("/keys/export/<committee_name>")
@@ -230,6 +335,35 @@ def _committee_keys(page: htm.Block, 
user_committees_with_keys: list[sql.Committ
                 page.p(".mb-4")["No keys uploaded for this committee yet."]
 
 
+async def _key_and_is_owner(
+    data: db.Session, session: web.Committer, fingerprint: str
+) -> tuple[sql.PublicSigningKey, bool]:
+    key = await data.public_signing_key(fingerprint=fingerprint, 
_committees=True).get()
+    if not key:
+        quart.abort(404, description="OpenPGP key not found")
+    key.committees.sort(key=lambda c: c.name)
+
+    # Allow owners and committee members to view the key
+    authorised = False
+    is_owner = False
+    if key.apache_uid and session.uid:
+        is_owner = key.apache_uid.lower() == session.uid.lower()
+    if is_owner:
+        authorised = True
+    else:
+        user_affiliations = set(session.committees + session.projects)
+        key_committee_names = {c.name for c in key.committees}
+        if user_affiliations.intersection(key_committee_names):
+            authorised = True
+        elif user.is_admin(session.uid):
+            authorised = True
+
+    if not authorised:
+        quart.abort(403, description="You are not authorised to view this key")
+
+    return key, is_owner
+
+
 def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> 
None:
     page.h3["Your OpenPGP keys"]
     if user_keys:
@@ -272,6 +406,32 @@ def _openpgp_keys(page: htm.Block, user_keys: 
list[sql.PublicSigningKey]) -> Non
         page.p[htm.strong["You haven't added any personal OpenPGP keys yet."]]
 
 
+def _render_committee_checkboxes(
+    committee_choices: list[tuple[str, str]], current_committees: list[str]
+) -> htm.Element:
+    """Render committee checkboxes in a grid layout."""
+    row_div = htm.Block(htm.div, classes=".row")
+    for val, label in committee_choices:
+        checkbox_id = f"selected_committees_{val}"
+        checkbox_attrs = {
+            "type": "checkbox",
+            "name": "selected_committees",
+            "id": checkbox_id,
+            "value": val,
+            "class_": "form-check-input",
+        }
+        if val in current_committees:
+            checkbox_attrs["checked"] = ""
+
+        checkbox_input = htpy.input(**checkbox_attrs)
+        checkbox_label = htpy.label(for_=checkbox_id, 
class_="form-check-label")[label]
+        checkbox_div = htm.div(".form-check.mb-2")[checkbox_input, 
checkbox_label]
+        col_div = htm.div(".col-sm-12.col-md-6.col-lg-4")[checkbox_div]
+        row_div.append(col_div)
+
+    return row_div.collect()
+
+
 def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
     page.h3["Your SSH keys"]
     if user_ssh_keys:
diff --git a/atr/htm.py b/atr/htm.py
index 3810099..7e62ee8 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -66,7 +66,7 @@ class BlockElementGetable:
         self.block = block
         self.element = element
 
-    def __getitem__(self, *items: Element | str | tuple[Element | str, ...]) 
-> Element:
+    def __getitem__(self, *items: Element | VoidElement | str | tuple[Element 
| VoidElement | str, ...]) -> Element:
         element = self.element[*items]
         for i in range(len(self.block.elements) - 1, -1, -1):
             if self.block.elements[i] is self.element:
@@ -190,6 +190,11 @@ class Block:
     def div(self) -> BlockElementCallable:
         return BlockElementCallable(self, div)
 
+    @property
+    def form(self) -> BlockElementCallable:
+        self.__check_parent("form", {"div"})
+        return BlockElementCallable(self, form)
+
     @property
     def h1(self) -> BlockElementCallable:
         self.__check_parent("h1", {"body", "div"})
diff --git a/atr/post/keys.py b/atr/post/keys.py
index fea0d3f..cf60b33 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -19,6 +19,7 @@
 import quart
 
 import atr.blueprints.post as post
+import atr.db as db
 import atr.get as get
 import atr.htm as htm
 import atr.log as log
@@ -88,60 +89,48 @@ async def keys(session: web.Committer, keys_form: 
shared.keys.KeysForm) -> web.W
             return await _update_committee_keys(session, update_committee_form)
 
 
-async def _delete_openpgp_key(
-    session: web.Committer, delete_form: shared.keys.DeleteOpenPGPKeyForm
-) -> web.WerkzeugResponse:
-    """Delete an OpenPGP key from the user's account."""
-    fingerprint = delete_form.fingerprint
-
-    async with storage.write() as write:
-        wafc = write.as_foundation_committer()
-        oc: outcome.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
-
-    match oc:
-        case outcome.Result():
-            return await session.redirect(get.keys.keys, success="OpenPGP key 
deleted successfully")
-        case outcome.Error(error):
-            return await session.redirect(get.keys.keys, error=f"Error 
deleting OpenPGP key: {error}")
-
-
-async def _delete_ssh_key(session: web.Committer, delete_form: 
shared.keys.DeleteSSHKeyForm) -> web.WerkzeugResponse:
-    """Delete an SSH key from the user's account."""
-    fingerprint = delete_form.fingerprint
-
-    async with storage.write() as write:
-        wafc = write.as_foundation_committer()
-        try:
-            await wafc.ssh.delete_key(fingerprint)
-        except storage.AccessError as e:
-            return await session.redirect(get.keys.keys, error=f"Error 
deleting SSH key: {e}")
-
-    return await session.redirect(get.keys.keys, success="SSH key deleted 
successfully")
-
-
-async def _update_committee_keys(
-    session: web.Committer, update_form: shared.keys.UpdateCommitteeKeysForm
[email protected]("/keys/details/<fingerprint>")
[email protected](shared.keys.UpdateKeyCommitteesForm)
+async def details(
+    session: web.Committer, update_form: shared.keys.UpdateKeyCommitteesForm, 
fingerprint: str
 ) -> web.WerkzeugResponse:
-    """Regenerate the KEYS file for a committee."""
-    committee_name = update_form.committee_name
-
-    async with storage.write() as write:
-        wacm = write.as_committee_member(committee_name)
-        match await wacm.keys.autogenerate_keys_file():
-            case outcome.Result():
-                await quart.flash(
-                    f'Successfully regenerated the KEYS file for the 
"{committee_name}" committee.', "success"
-                )
-            case outcome.Error():
-                await quart.flash(f"Error regenerating the KEYS file for the 
{committee_name} committee.", "error")
-
-    return await session.redirect(get.keys.keys)
+    """Update committee associations for an OpenPGP key."""
+    fingerprint = fingerprint.lower()
 
+    try:
+        async with db.session() as data:
+            key = await data.public_signing_key(fingerprint=fingerprint, 
_committees=True).get()
+            if not key:
+                await quart.flash("OpenPGP key not found", "error")
+                return await session.redirect(get.keys.keys)
+
+            if not (key.apache_uid and session.uid and (key.apache_uid.lower() 
== session.uid.lower())):
+                await quart.flash("You are not authorized to modify this key", 
"error")
+                return await session.redirect(get.keys.keys)
+
+            selected_committee_names = update_form.selected_committees
+            old_committee_names = {c.name for c in key.committees}
+
+            new_committees = await 
data.committee(name_in=selected_committee_names).all()
+            key.committees = list(new_committees)
+            data.add(key)
+            await data.commit()
+
+            affected_committee_names = 
old_committee_names.union(set(selected_committee_names))
+            if affected_committee_names:
+                async with storage.write() as write:
+                    for affected_committee_name in affected_committee_names:
+                        wacm = 
write.as_committee_member_outcome(affected_committee_name).result_or_none()
+                        if wacm is None:
+                            continue
+                        await wacm.keys.autogenerate_keys_file()
+
+            await quart.flash("Key committee associations updated 
successfully.", "success")
+    except Exception as e:
+        log.exception("Error updating key committee associations:")
+        await quart.flash(f"An unexpected error occurred: {e!s}", "error")
 
[email protected]("/keys/details/<fingerprint>")
-async def details(session: web.Committer, fingerprint: str) -> str | 
web.WerkzeugResponse:
-    """Display details for a specific OpenPGP key."""
-    return await shared.keys.details(session, fingerprint)
+    return await session.redirect(get.keys.details, fingerprint=fingerprint)
 
 
 @post.committer("/keys/import/<project_name>/<version_name>")
@@ -188,3 +177,53 @@ async def ssh_add(session: web.Committer, 
add_ssh_key_form: shared.keys.AddSSHKe
 async def upload(session: web.Committer) -> str:
     """Upload a KEYS file containing multiple OpenPGP keys."""
     return await shared.keys.upload(session)
+
+
+async def _delete_openpgp_key(
+    session: web.Committer, delete_form: shared.keys.DeleteOpenPGPKeyForm
+) -> web.WerkzeugResponse:
+    """Delete an OpenPGP key from the user's account."""
+    fingerprint = delete_form.fingerprint
+
+    async with storage.write() as write:
+        wafc = write.as_foundation_committer()
+        oc: outcome.Outcome[sql.PublicSigningKey] = await 
wafc.keys.delete_key(fingerprint)
+
+    match oc:
+        case outcome.Result():
+            return await session.redirect(get.keys.keys, success="OpenPGP key 
deleted successfully")
+        case outcome.Error(error):
+            return await session.redirect(get.keys.keys, error=f"Error 
deleting OpenPGP key: {error}")
+
+
+async def _delete_ssh_key(session: web.Committer, delete_form: 
shared.keys.DeleteSSHKeyForm) -> web.WerkzeugResponse:
+    """Delete an SSH key from the user's account."""
+    fingerprint = delete_form.fingerprint
+
+    async with storage.write() as write:
+        wafc = write.as_foundation_committer()
+        try:
+            await wafc.ssh.delete_key(fingerprint)
+        except storage.AccessError as e:
+            return await session.redirect(get.keys.keys, error=f"Error 
deleting SSH key: {e}")
+
+    return await session.redirect(get.keys.keys, success="SSH key deleted 
successfully")
+
+
+async def _update_committee_keys(
+    session: web.Committer, update_form: shared.keys.UpdateCommitteeKeysForm
+) -> web.WerkzeugResponse:
+    """Regenerate the KEYS file for a committee."""
+    committee_name = update_form.committee_name
+
+    async with storage.write() as write:
+        wacm = write.as_committee_member(committee_name)
+        match await wacm.keys.autogenerate_keys_file():
+            case outcome.Result():
+                await quart.flash(
+                    f'Successfully regenerated the KEYS file for the 
"{committee_name}" committee.', "success"
+                )
+            case outcome.Error():
+                await quart.flash(f"Error regenerating the KEYS file for the 
{committee_name} committee.", "error")
+
+    return await session.redirect(get.keys.keys)
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 20747af..e0c4aa3 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -18,7 +18,6 @@
 """keys.py"""
 
 import asyncio
-import datetime
 from collections.abc import Awaitable, Callable, Sequence
 from typing import Annotated, Literal
 
@@ -29,17 +28,14 @@ import quart
 import werkzeug.datastructures as datastructures
 import wtforms
 
-import atr.db as db
 import atr.form as form
 import atr.forms as forms
-import atr.get as get
 import atr.models.sql as sql
 import atr.shared as shared
 import atr.storage as storage
 import atr.storage.outcome as outcome
 import atr.storage.types as types
 import atr.template as template
-import atr.user as user
 import atr.util as util
 import atr.web as web
 
@@ -96,13 +92,11 @@ type KeysForm = Annotated[
 ]
 
 
-class UpdateKeyCommitteesForm(forms.Typed):
-    selected_committees = forms.checkboxes(
+class UpdateKeyCommitteesForm(form.Form):
+    selected_committees: form.StrList = form.label(
         "Associated PMCs",
-        optional=True,
-        description="Select the committees associated with this key.",
+        widget=form.Widget.CUSTOM,
     )
-    submit = forms.submit("Update associations")
 
 
 class UploadKeyFormBase(forms.Typed):
@@ -152,68 +146,6 @@ class UploadKeyFormBase(forms.Typed):
         return True
 
 
-async def details(session: web.Committer, fingerprint: str) -> str | 
web.WerkzeugResponse:
-    """Display details for a specific OpenPGP key."""
-    fingerprint = fingerprint.lower()
-    user_committees = []
-    current_committee_names = []
-    async with db.session() as data:
-        key, is_owner = await _key_and_is_owner(data, session, fingerprint)
-        form = None
-        if is_owner:
-            project_list = session.committees + session.projects
-            user_committees = await data.committee(name_in=project_list).all()
-            current_committee_names = [c.name for c in key.committees]
-    if is_owner:
-        committee_choices: forms.Choices = [(c.name, c.display_name or c.name) 
for c in user_committees]
-
-        # TODO: Probably need to do data in a separate phase
-        form = await UpdateKeyCommitteesForm.create_form(
-            data=await quart.request.form if (quart.request.method == "POST") 
else None
-        )
-        forms.choices(form.selected_committees, committee_choices)
-        if quart.request.method == "GET":
-            form.selected_committees.data = current_committee_names
-
-    if form and await form.validate_on_submit():
-        async with db.session() as data:
-            key = await data.public_signing_key(fingerprint=fingerprint, 
_committees=True).get()
-            if not key:
-                quart.abort(404, description="OpenPGP key not found")
-
-            selected_committee_names = form.selected_committees.data or []
-            old_committee_names = {c.name for c in key.committees}
-
-            new_committees = await 
data.committee(name_in=selected_committee_names).all()
-            key.committees = list(new_committees)
-            data.add(key)
-            await data.commit()
-
-            affected_committee_names = 
old_committee_names.union(set(selected_committee_names))
-            if affected_committee_names:
-                async with storage.write() as write:
-                    for affected_committee_name in affected_committee_names:
-                        wacm = 
write.as_committee_member_outcome(affected_committee_name).result_or_none()
-                        if wacm is None:
-                            continue
-                        await wacm.keys.autogenerate_keys_file()
-
-            await quart.flash("Key committee associations updated 
successfully.", "success")
-            return await session.redirect(get.keys.details, 
fingerprint=fingerprint)
-
-    if isinstance(key.ascii_armored_key, bytes):
-        key.ascii_armored_key = key.ascii_armored_key.decode("utf-8", 
errors="replace")
-
-    return await template.render(
-        "keys-details.html",
-        key=key,
-        form=form,
-        algorithms=shared.algorithms,
-        now=datetime.datetime.now(datetime.UTC),
-        asf_id=session.uid,
-    )
-
-
 async def upload(session: web.Committer) -> str:
     """Upload a KEYS file containing multiple OpenPGP keys."""
     async with storage.write() as write:
@@ -311,32 +243,3 @@ async def _get_keys_text(keys_url: str, render: 
Callable[[str], Awaitable[str]])
         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)
-
-
-async def _key_and_is_owner(
-    data: db.Session, session: web.Committer, fingerprint: str
-) -> tuple[sql.PublicSigningKey, bool]:
-    key = await data.public_signing_key(fingerprint=fingerprint, 
_committees=True).get()
-    if not key:
-        quart.abort(404, description="OpenPGP key not found")
-    key.committees.sort(key=lambda c: c.name)
-
-    # Allow owners and committee members to view the key
-    authorised = False
-    is_owner = False
-    if key.apache_uid and session.uid:
-        is_owner = key.apache_uid.lower() == session.uid.lower()
-    if is_owner:
-        authorised = True
-    else:
-        user_affiliations = set(session.committees + session.projects)
-        key_committee_names = {c.name for c in key.committees}
-        if user_affiliations.intersection(key_committee_names):
-            authorised = True
-        elif user.is_admin(session.uid):
-            authorised = True
-
-    if not authorised:
-        quart.abort(403, description="You are not authorised to view this key")
-
-    return key, is_owner
diff --git a/atr/templates/keys-details.html b/atr/templates/keys-details.html
deleted file mode 100644
index b55833d..0000000
--- a/atr/templates/keys-details.html
+++ /dev/null
@@ -1,108 +0,0 @@
-{% extends "layouts/base.html" %}
-
-{% block title %}
-  OpenPGP key details ~ ATR
-{% endblock title %}
-
-{% block description %}
-  View details for a specific OpenPGP public signing key.
-{% endblock description %}
-
-{% block content %}
-  <p>
-    <a href="{{ as_url(get.keys.keys) }}" class="atr-back-link">← Back to 
Manage keys</a>
-  </p>
-
-  <h1>OpenPGP key details</h1>
-
-  <table class="mb-0 table border border-2 table-striped table-sm">
-    <tbody>
-      <tr>
-        <th class="p-2 text-dark">Fingerprint</th>
-        <td class="text-break align-middle">{{ key.fingerprint.upper() }}</td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark">Type</th>
-        <td class="text-break align-middle">{{ algorithms[key.algorithm] }} 
({{ key.length }} bits)</td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark">Created</th>
-        <td class="text-break align-middle">{{ key.created.strftime("%Y-%m-%d 
%H:%M:%S") }}</td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark">Latest self signature</th>
-        <td class="text-break align-middle">
-          {{ key.latest_self_signature.strftime("%Y-%m-%d %H:%M:%S") if 
key.latest_self_signature else 'Never' }}
-        </td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark">Expires</th>
-        <td class="text-break align-middle">
-          {% if key.expires %}
-            {% set days_until_expiry = (key.expires - now).days %}
-            {% if days_until_expiry < 0 %}
-              <span class="text-danger fw-bold">
-                {{ key.expires.strftime("%Y-%m-%d %H:%M:%S") }}
-                <span class="badge bg-danger text-white ms-2">Expired</span>
-              </span>
-            {% elif days_until_expiry <= 30 %}
-              <span class="text-warning fw-bold">
-                {{ key.expires.strftime("%Y-%m-%d %H:%M:%S") }}
-                <span class="badge bg-warning text-dark ms-2">Expires in {{ 
days_until_expiry }} days</span>
-              </span>
-            {% else %}
-              {{ key.expires.strftime("%Y-%m-%d %H:%M:%S") }}
-            {% endif %}
-          {% else %}
-            Never
-          {% endif %}
-        </td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark">Primary UID</th>
-        <td class="text-break align-middle">{{ key.primary_declared_uid or '-' 
}}</td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark">Secondary UIDs</th>
-        <td class="text-break align-middle">
-          {{ key.secondary_declared_uids | join(", ") if 
key.secondary_declared_uids else '-' }}
-        </td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark">Apache UID</th>
-        <td class="text-break align-middle">{{ key.apache_uid }}</td>
-      </tr>
-      <tr>
-        <th class="p-2 text-dark align-top">Associated PMCs</th>
-        <td class="text-break pt-2">
-          {% if form %}
-            <form method="post" novalidate>
-              {{ form.hidden_tag() }}
-              <div class="row">
-                {% for subfield in form.selected_committees %}
-                  <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>
-                {% endfor %}
-              </div>
-              {{ forms.errors(form.selected_committees, 
classes="invalid-feedback d-block") }}
-              <div class="mt-3">{{ form.submit(class_='btn btn-primary 
btn-sm') }}</div>
-            </form>
-          {% else %}
-            {% if key.committees %}
-              {{ key.committees|map(attribute='name') |join(', ') }}
-            {% else %}
-              No PMCs associated
-            {% endif %}
-          {% endif %}
-        </td>
-      </tr>
-    </tbody>
-  </table>
-  <h2>ASCII armored key</h2>
-  <pre class="mt-3 border border-2 p-3">{{ key.ascii_armored_key }}</pre>
-  {# TODO: Add download button for the ASCII armored key #}
-{% endblock content %}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to