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 766dabb  Make several admin forms more type safe
766dabb is described below

commit 766dabbd51bf457ae8cba92814088a59eda14d09
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Nov 16 19:58:49 2025 +0000

    Make several admin forms more type safe
---
 atr/admin/__init__.py                      | 190 ++++++++++++++---------------
 atr/admin/templates/browse-as.html         |  22 +---
 atr/admin/templates/toggle-admin-view.html |  12 +-
 atr/admin/templates/update-keys.html       |  80 ++++++------
 atr/admin/templates/update-projects.html   |  80 ++++++------
 atr/blueprints/admin.py                    |  73 ++++++++++-
 atr/storage/writers/keys.py                |   6 +-
 7 files changed, 247 insertions(+), 216 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index a3795bd..9153f6d 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -38,6 +38,7 @@ import atr.config as config
 import atr.datasources.apache as apache
 import atr.db as db
 import atr.db.interaction as interaction
+import atr.form as form
 import atr.forms as forms
 import atr.get as get
 import atr.ldap as ldap
@@ -57,15 +58,8 @@ import atr.web as web
 ROUTES_MODULE: Final[Literal[True]] = True
 
 
-class BrowseAsUserForm(forms.Typed):
-    """Form for browsing as another user."""
-
-    uid = forms.string("ASF UID", placeholder="Enter the ASF UID to browse as")
-    submit = forms.submit("Browse as this user")
-
-
-class CheckKeysForm(forms.Typed):
-    submit = forms.submit("Check public signing key details")
+class BrowseAsUserForm(form.Form):
+    uid: str = form.label("ASF UID", "Enter the ASF UID to browse as.")
 
 
 class DeleteCommitteeKeysForm(forms.Typed):
@@ -90,20 +84,12 @@ class DeleteReleaseForm(forms.Typed):
     submit = forms.submit("Delete selected releases permanently")
 
 
-class DeleteTestKeysForm(forms.Typed):
-    submit = forms.submit("Delete all OpenPGP keys for test user")
-
-
 class LdapLookupForm(forms.Typed):
     uid = forms.optional("ASF UID (optional)", placeholder="Enter ASF UID, 
e.g. johnsmith, or * for all")
     email = forms.optional("Email address (optional)", placeholder="Enter 
email address, e.g. [email protected]")
     submit = forms.submit("Lookup")
 
 
-class RegenerateKeysForm(forms.Typed):
-    submit = forms.submit("Regenerate all KEYS files")
-
-
 @admin.get("/all-releases")
 async def all_releases(session: web.Committer) -> str:
     """Display a list of all releases across all phases."""
@@ -114,24 +100,22 @@ async def all_releases(session: web.Committer) -> str:
 
 @admin.get("/browse-as")
 async def browse_as_get(session: web.Committer) -> str | web.WerkzeugResponse:
-    return await _browse_as(session)
+    """Allows an admin to browse as another user."""
+    rendered_form = form.render(
+        model_cls=BrowseAsUserForm,
+        submit_label="Browse as this user",
+    )
+    return await template.render("browse-as.html", form=rendered_form)
 
 
 @admin.post("/browse-as")
-async def browse_as_post(session: web.Committer) -> str | web.WerkzeugResponse:
-    return await _browse_as(session)
-
-
-async def _browse_as(session: web.Committer) -> str | web.WerkzeugResponse:
[email protected](BrowseAsUserForm)
+async def browse_as_post(session: web.Committer, browse_form: 
BrowseAsUserForm) -> str | web.WerkzeugResponse:
     """Allows an admin to browse as another user."""
     # TODO: Enable this in debugging mode only?
     import atr.get.root as root
 
-    form = await BrowseAsUserForm.create_form()
-    if not (await form.validate_on_submit()):
-        return await template.render("browse-as.html", form=form)
-
-    new_uid = str(util.unwrap(form.uid.data))
+    new_uid = browse_form.uid
     if not (current_session := await asfquart.session.read()):
         raise base.ASFQuartException("Not authenticated", 401)
 
@@ -299,29 +283,40 @@ async def _data(session: web.Committer, model: str = 
"Committee") -> str:
 
 @admin.get("/delete-test-openpgp-keys")
 async def delete_test_openpgp_keys_get(session: web.Committer) -> web.Response:
+    """Display form to delete test user OpenPGP keys."""
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
 
-    delete_form = await DeleteTestKeysForm.create_form()
-    rendered_form = forms.render_simple(delete_form, action="")
+    rendered_form = form.render(
+        model_cls=form.Empty,
+        submit_label="Delete all OpenPGP keys for test user",
+        empty=True,
+    )
     return web.ElementResponse(rendered_form)
 
 
 @admin.post("/delete-test-openpgp-keys")
[email protected]()
 async def delete_test_openpgp_keys_post(session: web.Committer) -> 
web.Response:
     """Delete all test user OpenPGP keys and their links."""
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
 
     test_uid = "test"
-    delete_form = await DeleteTestKeysForm.create_form()
-    if not await delete_form.validate_on_submit():
-        raise base.ASFQuartException("Invalid form submission. Please check 
your input and try again.", errorcode=400)
-
-    async with storage.write() as write:
-        wafc = write.as_foundation_committer()
-        outcome = await wafc.keys.test_user_delete_all(test_uid)
-        outcome.result_or_raise()
+    try:
+        async with storage.write() as write:
+            wafc = write.as_foundation_committer()
+            delete_outcome = await wafc.keys.test_user_delete_all(test_uid)
+            deleted_count = delete_outcome.result_or_raise()
+
+        suffix = "s" if deleted_count != 1 else ""
+        await quart.flash(
+            f"Successfully deleted {deleted_count} OpenPGP key{suffix} and 
their associated links for test user.",
+            "success",
+        )
+    except Exception as e:
+        log.exception("Error deleting test user keys:")
+        await quart.flash(f"Error deleting test user keys: {e!s}", "error")
 
     return await session.redirect(get.keys.keys)
 
@@ -437,21 +432,18 @@ async def env(session: web.Committer) -> 
web.QuartResponse:
 
 @admin.get("/keys/check")
 async def keys_check_get(session: web.Committer) -> web.QuartResponse:
-    return await _keys_check(session)
+    """Check public signing key details."""
+    rendered_form = form.render(
+        model_cls=form.Empty,
+        submit_label="Check public signing key details",
+        empty=True,
+    )
+    return web.ElementResponse(rendered_form)
 
 
 @admin.post("/keys/check")
 async def keys_check_post(session: web.Committer) -> web.QuartResponse:
-    return await _keys_check(session)
-
-
-async def _keys_check(session: web.Committer) -> web.QuartResponse:
     """Check public signing key details."""
-    if quart.request.method != "POST":
-        check_form = await CheckKeysForm.create_form()
-        rendered_form = forms.render_simple(check_form, action="")
-        return web.ElementResponse(rendered_form)
-
     try:
         result = await _check_keys()
         return web.TextResponse(result)
@@ -462,21 +454,18 @@ async def _keys_check(session: web.Committer) -> 
web.QuartResponse:
 
 @admin.get("/keys/regenerate-all")
 async def keys_regenerate_all_get(session: web.Committer) -> web.QuartResponse:
-    return await _keys_regenerate_all(session)
+    """Display form to regenerate KEYS files."""
+    rendered_form = form.render(
+        model_cls=form.Empty,
+        submit_label="Regenerate all KEYS files",
+        empty=True,
+    )
+    return web.ElementResponse(rendered_form)
 
 
 @admin.post("/keys/regenerate-all")
 async def keys_regenerate_all_post(session: web.Committer) -> 
web.QuartResponse:
-    return await _keys_regenerate_all(session)
-
-
-async def _keys_regenerate_all(session: web.Committer) -> web.QuartResponse:
     """Regenerate the KEYS file for all committees."""
-    if quart.request.method != "POST":
-        regenerate_form = await RegenerateKeysForm.create_form()
-        rendered_form = forms.render_simple(regenerate_form, action="")
-        return web.ElementResponse(rendered_form)
-
     async with db.session() as data:
         committee_names = [c.name for c in await data.committee().all()]
 
@@ -500,27 +489,25 @@ async def _keys_regenerate_all(session: web.Committer) -> 
web.QuartResponse:
 
 @admin.get("/keys/update")
 async def keys_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    return await _keys_update(session)
+    """Update keys from remote data."""
+    rendered_form = form.render(
+        model_cls=form.Empty,
+        submit_label="Update keys",
+        empty=True,
+        form_classes="",
+    )
+    log_path = pathlib.Path("keys_import.log")
+    if not await aiofiles.os.path.exists(log_path):
+        previous_output = None
+    else:
+        async with aiofiles.open(log_path) as f:
+            previous_output = await f.read()
+    return await template.render("update-keys.html", empty_form=rendered_form, 
previous_output=previous_output)
 
 
 @admin.post("/keys/update")
 async def keys_update_post(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    return await _keys_update(session)
-
-
-async def _keys_update(session: web.Committer) -> str | web.WerkzeugResponse | 
tuple[Mapping[str, Any], int]:
     """Update keys from remote data."""
-    if quart.request.method != "POST":
-        empty_form = await forms.Empty.create_form()
-        # Get the previous output from the log file
-        log_path = pathlib.Path("keys_import.log")
-        if not await aiofiles.os.path.exists(log_path):
-            previous_output = None
-        else:
-            async with aiofiles.open(log_path) as f:
-                previous_output = await f.read()
-        return await template.render("update-keys.html", 
empty_form=empty_form, previous_output=previous_output)
-
     try:
         pid = await _update_keys(session.asf_uid)
         return {
@@ -702,33 +689,31 @@ async def performance(session: web.Committer) -> str:
 
 @admin.get("/projects/update")
 async def projects_update_get(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    return await _projects_update(session)
+    """Update projects from remote data."""
+    rendered_form = form.render(
+        model_cls=form.Empty,
+        submit_label="Update projects",
+        empty=True,
+        form_classes="",
+    )
+    return await template.render("update-projects.html", 
empty_form=rendered_form)
 
 
 @admin.post("/projects/update")
 async def projects_update_post(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
-    return await _projects_update(session)
-
-
-async def _projects_update(session: web.Committer) -> str | 
web.WerkzeugResponse | tuple[Mapping[str, Any], int]:
     """Update projects from remote data."""
-    if quart.request.method == "POST":
-        try:
-            task = await tasks.metadata_update(session.asf_uid)
-            return {
-                "message": f"Metadata update task has been queued with ID 
{task.id}.",
-                "category": "success",
-            }, 200
-        except Exception as e:
-            log.exception("Failed to queue metadata update task")
-            return {
-                "message": f"Failed to queue metadata update: {e!s}",
-                "category": "error",
-            }, 200
-
-    # For GET requests, show the update form
-    empty_form = await forms.Empty.create_form()
-    return await template.render("update-projects.html", empty_form=empty_form)
+    try:
+        task = await tasks.metadata_update(session.asf_uid)
+        return {
+            "message": f"Metadata update task has been queued with ID 
{task.id}.",
+            "category": "success",
+        }, 200
+    except Exception as e:
+        log.exception("Failed to queue metadata update task")
+        return {
+            "message": f"Failed to queue metadata update: {e!s}",
+            "category": "error",
+        }, 200
 
 
 @admin.get("/tasks")
@@ -790,14 +775,19 @@ async def test(session: web.Committer) -> 
web.QuartResponse:
 @admin.get("/toggle-view")
 async def toggle_view_get(session: web.Committer) -> str:
     """Display the page with a button to toggle between admin and user 
views."""
-    empty_form = await forms.Empty.create_form()
-    return await template.render("toggle-admin-view.html", 
empty_form=empty_form)
+    rendered_form = form.render(
+        model_cls=form.Empty,
+        submit_label="Toggle view",
+        empty=True,
+        form_classes=".mb-4",
+    )
+    return await template.render("toggle-admin-view.html", 
empty_form=rendered_form)
 
 
 @admin.post("/toggle-view")
[email protected]()
 async def toggle_view_post(session: web.Committer) -> web.WerkzeugResponse:
-    await util.validate_empty_form()
-
+    """Toggle between admin and user views."""
     app = asfquart.APP
     if not hasattr(app, "app_id") or not isinstance(app.app_id, str):
         raise TypeError("Internal error: APP has no valid app_id")
diff --git a/atr/admin/templates/browse-as.html 
b/atr/admin/templates/browse-as.html
index 83b8830..28036f5 100644
--- a/atr/admin/templates/browse-as.html
+++ b/atr/admin/templates/browse-as.html
@@ -14,25 +14,5 @@
         This will allow you to view the site as that user.
     </p>
 
-    <form method="post" class="form">
-        {{ form.csrf_token }}
-        <div class="field mb-3">
-            <label class="label" for="uid">{{ form.uid.label }}</label>
-            <div class="control">
-                {{ form.uid(class="form-control") }}
-            </div>
-            {% if form.uid.errors %}
-                <div class="invalid-feedback d-block">
-                    {% for error in form.uid.errors %}
-                        <span>{{ error }}</span>
-                    {% endfor %}
-                </div>
-            {% endif %}
-        </div>
-        <div class="field">
-            <div class="control">
-                {{ form.submit(class="btn btn-primary") }}
-            </div>
-        </div>
-    </form>
+    {{ form }}
 {% endblock %}
diff --git a/atr/admin/templates/toggle-admin-view.html 
b/atr/admin/templates/toggle-admin-view.html
index b39910b..2dd7bf9 100644
--- a/atr/admin/templates/toggle-admin-view.html
+++ b/atr/admin/templates/toggle-admin-view.html
@@ -15,17 +15,7 @@
   </p>
 
   {% if current_user and is_admin_fn(current_user.uid) %}
-    <form action="{{ as_url(admin.toggle_view_post) }}" method="post" 
class="mb-4">
-      {{ empty_form.hidden_tag() }}
-
-      <button type="submit" class="btn btn-primary">
-        {% if not is_viewing_as_admin_fn(current_user.uid) %}
-          <i class="fa-solid fa-user-shield"></i> Switch to admin view
-        {% else %}
-          <i class="fa-solid fa-user-ninja"></i> Switch to user view
-        {% endif %}
-      </button>
-    </form>
+    {{ empty_form }}
 
     <div class="alert alert-info" role="alert">
       Current view mode:
diff --git a/atr/admin/templates/update-keys.html 
b/atr/admin/templates/update-keys.html
index 4c23eed..2184c12 100644
--- a/atr/admin/templates/update-keys.html
+++ b/atr/admin/templates/update-keys.html
@@ -85,11 +85,7 @@
 
   <div id="status"></div>
 
-  <form action="javascript:submitForm().then(_ => { return false; })">
-    {{ empty_form.hidden_tag() }}
-
-    <button type="submit" id="submitButton">Update keys</button>
-  </form>
+  {{ empty_form }}
 
   {% if previous_output %}
     <h2>Previous output</h2>
@@ -97,40 +93,46 @@
   {% endif %}
 
   <script>
-    const submitForm = async () => {
-      const button = document.getElementById("submitButton");
-      button.disabled = true;
-      document.body.style.cursor = "wait";
-
-      const statusElement = document.getElementById("status");
-      while (statusElement.firstChild) {
-        statusElement.firstChild.remove();
-      }
-
-      const csrfToken = 
document.querySelector("input[name='csrf_token']").value;
-
-      try {
-          const response = await fetch(window.location.href, {
-              method: "POST",
-              headers: {
-                  "X-CSRFToken": csrfToken
-              }
-          });
-
-          if (!response.ok) {
-              addStatusMessage(statusElement, "Could not make network 
request", "error");
-              return
-          }
-
-          const data = await response.json();
-          addStatusMessage(statusElement, data.message, data.category)
-      } catch (error) {
-          addStatusMessage(statusElement, error, "error")
-      } finally {
-        button.disabled = false;
-        document.body.style.cursor = "default";
-      }
-    };
+    document.addEventListener('DOMContentLoaded', () => {
+      const form = document.querySelector('form');
+      const button = form.querySelector('button[type="submit"]');
+
+      form.addEventListener('submit', async (e) => {
+        e.preventDefault();
+
+        button.disabled = true;
+        document.body.style.cursor = "wait";
+
+        const statusElement = document.getElementById("status");
+        while (statusElement.firstChild) {
+          statusElement.firstChild.remove();
+        }
+
+        const csrfToken = 
document.querySelector("input[name='csrf_token']").value;
+
+        try {
+            const response = await fetch(window.location.href, {
+                method: "POST",
+                headers: {
+                    "X-CSRFToken": csrfToken
+                }
+            });
+
+            if (!response.ok) {
+                addStatusMessage(statusElement, "Could not make network 
request", "error");
+                return
+            }
+
+            const data = await response.json();
+            addStatusMessage(statusElement, data.message, data.category)
+        } catch (error) {
+            addStatusMessage(statusElement, error, "error")
+        } finally {
+          button.disabled = false;
+          document.body.style.cursor = "default";
+        }
+      });
+    });
 
     function addStatusMessage(parentElement, message, category) {
       const divElement = document.createElement("div");
diff --git a/atr/admin/templates/update-projects.html 
b/atr/admin/templates/update-projects.html
index c997001..a0f73cc 100644
--- a/atr/admin/templates/update-projects.html
+++ b/atr/admin/templates/update-projects.html
@@ -90,47 +90,49 @@
 
   <div id="status"></div>
 
-  <form action="javascript:submitForm().then(_ => { return false; })">
-    {{ empty_form.hidden_tag() }}
-
-    <button type="submit" id="submitButton">Update projects</button>
-  </form>
+  {{ empty_form }}
 
   <script>
-    const submitForm = async () => {
-      const button = document.getElementById("submitButton");
-      button.disabled = true;
-      document.body.style.cursor = "wait";
-
-      const statusElement = document.getElementById("status");
-      while (statusElement.firstChild) {
-        statusElement.firstChild.remove();
-      }
-
-      const csrfToken = 
document.querySelector("input[name='csrf_token']").value;
-
-      try {
-          const response = await fetch(window.location.href, {
-              method: "POST",
-              headers: {
-                  "X-CSRFToken": csrfToken
-              }
-          });
-
-          if (!response.ok) {
-              addStatusMessage(statusElement, "Could not make network 
request", "error");
-              return
-          }
-
-          const data = await response.json();
-          addStatusMessage(statusElement, data.message, data.category)
-      } catch (error) {
-          addStatusMessage(statusElement, error, "error")
-      } finally {
-        button.disabled = false;
-        document.body.style.cursor = "default";
-      }
-    };
+    document.addEventListener('DOMContentLoaded', () => {
+      const form = document.querySelector('form');
+      const button = form.querySelector('button[type="submit"]');
+
+      form.addEventListener('submit', async (e) => {
+        e.preventDefault();
+
+        button.disabled = true;
+        document.body.style.cursor = "wait";
+
+        const statusElement = document.getElementById("status");
+        while (statusElement.firstChild) {
+          statusElement.firstChild.remove();
+        }
+
+        const csrfToken = 
document.querySelector("input[name='csrf_token']").value;
+
+        try {
+            const response = await fetch(window.location.href, {
+                method: "POST",
+                headers: {
+                    "X-CSRFToken": csrfToken
+                }
+            });
+
+            if (!response.ok) {
+                addStatusMessage(statusElement, "Could not make network 
request", "error");
+                return
+            }
+
+            const data = await response.json();
+            addStatusMessage(statusElement, data.message, data.category)
+        } catch (error) {
+            addStatusMessage(statusElement, error, "error")
+        } finally {
+          button.disabled = false;
+          document.body.style.cursor = "default";
+        }
+      });
+    });
 
     function addStatusMessage(parentElement, message, category) {
       const divElement = document.createElement("div");
diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index baccd93..88eeeab 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -15,14 +15,17 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from collections.abc import Callable
+import json
+from collections.abc import Awaitable, Callable
 from types import ModuleType
 from typing import Any
 
 import asfquart.base as base
 import asfquart.session
+import pydantic
 import quart
 
+import atr.form
 import atr.user as user
 import atr.web as web
 
@@ -42,11 +45,64 @@ async def _check_admin_access() -> None:
     quart.g.session = web.Committer(web_session)
 
 
-def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
-    import atr.admin as admin
+def empty() -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., 
Awaitable[Any]]]:
+    def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., 
Awaitable[Any]]:
+        async def wrapper(session: web.Committer, *args: Any, **kwargs: Any) 
-> Any:
+            form_data = await quart.request.form
+            try:
+                context = {
+                    "args": args,
+                    "kwargs": kwargs,
+                    "session": session,
+                }
+                atr.form.validate(atr.form.Empty, dict(form_data), 
context=context)
+                return await func(session, *args, **kwargs)
+            except pydantic.ValidationError:
+                msg = "Sorry, there was an empty form validation error. Please 
try again."
+                await quart.flash(msg, "error")
+                return quart.redirect(quart.request.path)
+
+        wrapper.__annotations__ = func.__annotations__.copy()
+        wrapper.__doc__ = func.__doc__
+        wrapper.__module__ = func.__module__
+        wrapper.__name__ = func.__name__
+        return wrapper
 
-    app.register_blueprint(_BLUEPRINT)
-    return admin, []
+    return decorator
+
+
+def form(
+    form_cls: Any,
+) -> Callable[[Callable[..., Awaitable[Any]]], Callable[..., Awaitable[Any]]]:
+    def decorator(func: Callable[..., Awaitable[Any]]) -> Callable[..., 
Awaitable[Any]]:
+        async def wrapper(session: web.Committer, *args: Any, **kwargs: Any) 
-> Any:
+            form_data = await quart.request.form
+            try:
+                context = {
+                    "args": args,
+                    "kwargs": kwargs,
+                    "session": session,
+                }
+                validated_form = atr.form.validate(form_cls, dict(form_data), 
context=context)
+                return await func(session, validated_form, *args, **kwargs)
+            except pydantic.ValidationError as e:
+                errors = e.errors()
+                if len(errors) == 0:
+                    raise RuntimeError("Validation failed, but no errors were 
reported")
+                flash_data = atr.form.flash_error_data(form_cls, errors, 
dict(form_data))
+                summary = atr.form.flash_error_summary(errors, flash_data)
+
+                await quart.flash(summary, category="error")
+                await quart.flash(json.dumps(flash_data), 
category="form-error-data")
+                return quart.redirect(quart.request.path)
+
+        wrapper.__annotations__ = func.__annotations__.copy()
+        wrapper.__doc__ = func.__doc__
+        wrapper.__module__ = func.__module__
+        wrapper.__name__ = func.__name__
+        return wrapper
+
+    return decorator
 
 
 def get(path: str) -> Callable[[web.CommitterRouteFunction[Any]], 
web.RouteFunction[Any]]:
@@ -81,3 +137,10 @@ def post(path: str) -> 
Callable[[web.CommitterRouteFunction[Any]], web.RouteFunc
         return wrapper
 
     return decorator
+
+
+def register(app: base.QuartApp) -> tuple[ModuleType, list[str]]:
+    import atr.admin as admin
+
+    app.register_blueprint(_BLUEPRINT)
+    return admin, []
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index 4b71d1d..57a6f45 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -184,10 +184,14 @@ class FoundationCommitter(GeneralPublic):
             return outcome.Error(storage.AccessError("Test key deletion not 
enabled"))
 
         try:
-            test_user_keys = await 
self.__data.public_signing_key(apache_uid=test_uid).all()
+            test_user_keys = await 
self.__data.public_signing_key(apache_uid=test_uid, _committees=True).all()
 
             deleted_count = 0
             for key in test_user_keys:
+                # We must do this here otherwise SQLAlchemy does not know 
about the deletions
+                key.committees.clear()
+                await self.__data.flush()
+
                 keylinks_query = 
sqlmodel.select(sql.KeyLink).where(sql.KeyLink.key_fingerprint == 
key.fingerprint)
                 keylinks_result = await self.__data.execute(keylinks_query)
                 keylinks = keylinks_result.all()


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

Reply via email to