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]