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 65dfe39  Move admin routes into the improved blueprints layout
65dfe39 is described below

commit 65dfe39a99a29d0ebccfa54a06b4814cb8350db5
Author: Sean B. Palmer <[email protected]>
AuthorDate: Sun Oct 26 20:03:45 2025 +0000

    Move admin routes into the improved blueprints layout
---
 atr/{bps/admin/admin.py => admin/__init__.py}      | 284 ++++++++++++---------
 atr/{bps => }/admin/templates/all-releases.html    |   0
 atr/{bps => }/admin/templates/browse-as.html       |   0
 atr/{bps => }/admin/templates/data-browser.html    |   2 +-
 atr/{bps => }/admin/templates/delete-release.html  |   0
 atr/{bps => }/admin/templates/ldap-lookup.html     |   2 +-
 atr/{bps => }/admin/templates/performance.html     |   0
 atr/{bps => }/admin/templates/tasks.html           |   0
 .../admin/templates/toggle-admin-view.html         |   2 +-
 atr/{bps => }/admin/templates/update-keys.html     |   0
 atr/{bps => }/admin/templates/update-projects.html |   0
 atr/{bps => }/admin/templates/validation.html      |   0
 atr/blueprints/__init__.py                         |   2 +
 atr/blueprints/{get.py => admin.py}                |  75 +++---
 atr/blueprints/get.py                              |  11 +-
 atr/bps/__init__.py                                |   2 -
 atr/bps/admin/__init__.py                          |  43 ----
 atr/principal.py                                   |  16 +-
 atr/server.py                                      |   2 +
 atr/templates/delete-committee-keys.html           |   2 +-
 atr/templates/includes/sidebar.html                |  64 ++---
 21 files changed, 253 insertions(+), 254 deletions(-)

diff --git a/atr/bps/admin/admin.py b/atr/admin/__init__.py
similarity index 81%
rename from atr/bps/admin/admin.py
rename to atr/admin/__init__.py
index 01972c1..2230452 100644
--- a/atr/bps/admin/admin.py
+++ b/atr/admin/__init__.py
@@ -23,18 +23,18 @@ import statistics
 import sys
 import time
 from collections.abc import Callable, Mapping
-from typing import Any
+from typing import Any, Final, Literal
 
 import aiofiles.os
 import aiohttp
 import asfquart
 import asfquart.base as base
-import asfquart.session as session
+import asfquart.session
 import quart
 import sqlalchemy.orm as orm
 import werkzeug.wrappers.response as response
 
-import atr.bps.admin as admin
+import atr.blueprints.admin as admin
 import atr.config as config
 import atr.datasources.apache as apache
 import atr.db as db
@@ -44,8 +44,8 @@ import atr.ldap as ldap
 import atr.log as log
 import atr.models.sql as sql
 import atr.principal as principal
-import atr.route as route
 import atr.routes.mapping as mapping
+import atr.session as session
 import atr.storage as storage
 import atr.storage.outcome as outcome
 import atr.storage.types as types
@@ -54,6 +54,8 @@ import atr.template as template
 import atr.util as util
 import atr.validate as validate
 
+ROUTES_MODULE: Final[Literal[True]] = True
+
 
 class BrowseAsUserForm(forms.Typed):
     """Form for browsing as another user."""
@@ -90,16 +92,25 @@ class LdapLookupForm(forms.Typed):
     submit = forms.submit("Lookup")
 
 
[email protected]("/all-releases")
-async def admin_all_releases() -> str:
[email protected]("/all-releases")
+async def all_releases(session: session.Committer) -> str:
     """Display a list of all releases across all phases."""
     async with db.session() as data:
         releases = await data.release(_project=True, 
_committee=True).order_by(sql.Release.name).all()
     return await template.render("all-releases.html", releases=releases, 
release_as_url=mapping.release_as_url)
 
 
[email protected]("/browse-as", methods=["GET", "POST"])
-async def admin_browse_as() -> str | response.Response:
[email protected]("/browse-as")
+async def browse_as_get(session: session.Committer) -> str | response.Response:
+    return await _browse_as(session)
+
+
[email protected]("/browse-as")
+async def browse_as_post(session: session.Committer) -> str | 
response.Response:
+    return await _browse_as(session)
+
+
+async def _browse_as(session: session.Committer) -> str | response.Response:
     """Allows an admin to browse as another user."""
     # TODO: Enable this in debugging mode only?
     from atr.routes import root
@@ -109,7 +120,7 @@ async def admin_browse_as() -> str | response.Response:
         return await template.render("browse-as.html", form=form)
 
     new_uid = str(util.unwrap(form.uid.data))
-    if not (current_session := await session.read()):
+    if not (current_session := await asfquart.session.read()):
         raise base.ASFQuartException("Not authenticated", 401)
 
     bind_dn, bind_password = principal.get_ldap_bind_dn_and_password()
@@ -122,7 +133,7 @@ async def admin_browse_as() -> str | response.Response:
 
     if not ldap_params.results_list:
         await quart.flash(f"User '{new_uid}' not found in LDAP.", "error")
-        return quart.redirect(quart.url_for("admin.admin_browse_as"))
+        return await session.redirect(browse_as_get)
 
     ldap_projects_data = await apache.get_ldap_projects_data()
     committee_data = await apache.get_active_committee_data()
@@ -138,17 +149,17 @@ async def admin_browse_as() -> str | response.Response:
         bind_password,
     )
     log.info("New Quart cookie (not ASFQuart session) data: %s", 
new_session_data)
-    session.write(new_session_data)
+    asfquart.session.write(new_session_data)
 
     await quart.flash(
         f"You are now browsing as '{new_uid}'. To return to your own account, 
please log out and log back in.",
         "success",
     )
-    return quart.redirect(util.as_url(root.index))
+    return await session.redirect(root.index)
 
 
[email protected]("/config")
-async def admin_config() -> quart.wrappers.response.Response:
[email protected]("/configuration")
+async def configuration(session: session.Committer) -> 
quart.wrappers.response.Response:
     """Display the current application configuration values."""
 
     conf = config.get()
@@ -170,8 +181,8 @@ async def admin_config() -> 
quart.wrappers.response.Response:
     return quart.Response("\n".join(values), mimetype="text/plain")
 
 
[email protected]("/consistency")
-async def admin_consistency() -> quart.Response:
[email protected]("/consistency")
+async def consistency(session: session.Committer) -> quart.Response:
     """Check for consistency between the database and the filesystem."""
     # Get all releases from the database
     async with db.session() as data:
@@ -218,9 +229,9 @@ Paired correctly:
     )
 
 
[email protected]("/data")
[email protected]("/data/<model>")
-async def admin_data(model: str = "Committee") -> str:
[email protected]("/data")
[email protected]("/data/<model>")
+async def data(session: session.Committer, model: str = "Committee") -> str:
     """Browse all records in the database."""
     async with db.session() as data:
         # Map of model names to their classes
@@ -267,9 +278,20 @@ async def admin_data(model: str = "Committee") -> str:
         )
 
 
[email protected]("/delete-test-openpgp-keys", methods=["GET", "POST"])
-async def admin_delete_test_openpgp_keys() -> quart.Response | 
response.Response:
[email protected]("/delete-test-openpgp-keys")
+async def delete_test_openpgp_keys_get(session: session.Committer) -> 
quart.Response | response.Response:
+    return await _delete_test_openpgp_keys(session)
+
+
[email protected]("/delete-test-openpgp-keys")
+async def delete_test_openpgp_keys_post(session: session.Committer) -> 
quart.Response | response.Response:
+    return await _delete_test_openpgp_keys(session)
+
+
+async def _delete_test_openpgp_keys(session: session.Committer) -> 
quart.Response | response.Response:
     """Delete all test user OpenPGP keys and their links."""
+    import atr.routes
+
     if not config.get().ALLOW_TESTS:
         raise base.ASFQuartException("Test operations are disabled in this 
environment", errorcode=403)
 
@@ -295,11 +317,20 @@ async def admin_delete_test_openpgp_keys() -> 
quart.Response | response.Response
         outcome = await wafc.keys.test_user_delete_all(test_uid)
         outcome.result_or_raise()
 
-    return quart.redirect("/keys")
+    return await session.redirect(atr.routes.keys.keys)
+
+
[email protected]("/delete-committee-keys")
+async def delete_committee_keys_get(session: session.Committer) -> str | 
response.Response:
+    return await _delete_committee_keys(session)
+
+
[email protected]("/delete-committee-keys")
+async def delete_committee_keys_post(session: session.Committer) -> str | 
response.Response:
+    return await _delete_committee_keys(session)
 
 
[email protected]("/delete-committee-keys", methods=["GET", "POST"])
-async def admin_delete_committee_keys() -> str | response.Response:
+async def _delete_committee_keys(session: session.Committer) -> str | 
response.Response:
     form = await DeleteCommitteeKeysForm.create_form()
     async with db.session() as data:
         all_committees = await 
data.committee(_public_signing_keys=True).order_by(sql.Committee.name).all()
@@ -320,12 +351,12 @@ async def admin_delete_committee_keys() -> str | 
response.Response:
 
             if not committee:
                 await quart.flash(f"Committee '{committee_name}' not found.", 
"error")
-                return 
quart.redirect(quart.url_for("admin.admin_delete_committee_keys"))
+                return await session.redirect(delete_committee_keys_get)
 
             keys_to_check = list(committee.public_signing_keys)
             if not keys_to_check:
                 await quart.flash(f"Committee '{committee_name}' has no 
keys.", "info")
-                return 
quart.redirect(quart.url_for("admin.admin_delete_committee_keys"))
+                return await session.redirect(delete_committee_keys_get)
 
             num_removed = len(committee.public_signing_keys)
             committee.public_signing_keys.clear()
@@ -342,7 +373,7 @@ async def admin_delete_committee_keys() -> str | 
response.Response:
                 f"Removed {num_removed} key links for '{committee_name}'. 
Deleted {unused_deleted} unused keys.",
                 "success",
             )
-        return 
quart.redirect(quart.url_for("admin.admin_delete_committee_keys"))
+        return await session.redirect(delete_committee_keys_get)
 
     elif quart.request.method == "POST":
         await quart.flash("Form validation failed. Select committee and type 
DELETE KEYS.", "warning")
@@ -350,18 +381,20 @@ async def admin_delete_committee_keys() -> str | 
response.Response:
     return await template.render("delete-committee-keys.html", form=form)
 
 
[email protected]("/delete-release", methods=["GET", "POST"])
-async def admin_delete_release() -> str | response.Response:
[email protected]("/delete-release")
+async def delete_release_get(session: session.Committer) -> str | 
response.Response:
+    return await _delete_release(session)
+
+
[email protected]("/delete-release")
+async def delete_release_post(session: session.Committer) -> str | 
response.Response:
+    return await _delete_release(session)
+
+
+async def _delete_release(session: session.Committer) -> str | 
response.Response:
     """Page to delete selected releases and their associated data and files."""
     form = await DeleteReleaseForm.create_form()
 
-    web_session = await session.read()
-    if web_session is None:
-        raise base.ASFQuartException("Not authenticated", 401)
-    asf_uid = web_session.uid
-    if asf_uid is None:
-        raise base.ASFQuartException("Invalid session, uid is None", 500)
-
     if quart.request.method == "POST":
         if await form.validate_on_submit():
             form_data = await quart.request.form
@@ -369,13 +402,12 @@ async def admin_delete_release() -> str | 
response.Response:
 
             if not releases_to_delete:
                 await quart.flash("No releases selected for deletion.", 
"warning")
-                return 
quart.redirect(quart.url_for("admin.admin_delete_release"))
+                return await session.redirect(delete_release_get)
 
-            committer_session = route.CommitterSession(web_session)
-            await _delete_releases(committer_session, releases_to_delete)
+            await _delete_releases(session, releases_to_delete)
 
             # Redirecting back to the deletion page will refresh the list of 
releases too
-            return quart.redirect(quart.url_for("admin.admin_delete_release"))
+            return await session.redirect(delete_release_get)
 
         # It's unlikely that form validation failed due to spurious release 
names
         # Therefore we assume that the user forgot to type DELETE to confirm
@@ -388,8 +420,8 @@ async def admin_delete_release() -> str | response.Response:
     return await template.render("delete-release.html", form=form, 
releases=releases, stats=None)
 
 
[email protected]("/env")
-async def admin_env() -> quart.wrappers.response.Response:
[email protected]("/env")
+async def env(session: session.Committer) -> quart.wrappers.response.Response:
     """Display the environment variables."""
     env_vars = []
     for key, value in os.environ.items():
@@ -397,8 +429,17 @@ async def admin_env() -> quart.wrappers.response.Response:
     return quart.Response("\n".join(env_vars), mimetype="text/plain")
 
 
[email protected]("/keys/check", methods=["GET", "POST"])
-async def admin_keys_check() -> quart.Response:
[email protected]("/keys/check")
+async def keys_check_get(session: session.Committer) -> quart.Response:
+    return await _keys_check(session)
+
+
[email protected]("/keys/check")
+async def keys_check_post(session: session.Committer) -> quart.Response:
+    return await _keys_check(session)
+
+
+async def _keys_check(session: session.Committer) -> quart.Response:
     """Check public signing key details."""
     if quart.request.method != "POST":
         empty_form = await forms.Empty.create_form()
@@ -420,8 +461,17 @@ async def admin_keys_check() -> quart.Response:
         return quart.Response(f"Exception during key check: {e!s}", 
mimetype="text/plain")
 
 
[email protected]("/keys/regenerate-all", methods=["GET", "POST"])
-async def admin_keys_regenerate_all() -> quart.Response:
[email protected]("/keys/regenerate-all")
+async def keys_regenerate_all_get(session: session.Committer) -> 
quart.Response:
+    return await _keys_regenerate_all(session)
+
+
[email protected]("/keys/regenerate-all")
+async def keys_regenerate_all_post(session: session.Committer) -> 
quart.Response:
+    return await _keys_regenerate_all(session)
+
+
+async def _keys_regenerate_all(session: session.Committer) -> quart.Response:
     """Regenerate the KEYS file for all committees."""
     if quart.request.method != "POST":
         empty_form = await forms.Empty.create_form()
@@ -438,13 +488,6 @@ async def admin_keys_regenerate_all() -> quart.Response:
     async with db.session() as data:
         committee_names = [c.name for c in await data.committee().all()]
 
-    web_session = await session.read()
-    if web_session is None:
-        raise base.ASFQuartException("Not authenticated", 401)
-    asf_uid = web_session.uid
-    if asf_uid is None:
-        raise base.ASFQuartException("Invalid session, uid is None", 500)
-
     outcomes = outcome.List[str]()
     async with storage.write() as write:
         for committee_name in committee_names:
@@ -463,8 +506,17 @@ async def admin_keys_regenerate_all() -> quart.Response:
     return quart.Response("\n".join(response_lines), mimetype="text/plain")
 
 
[email protected]("/keys/update", methods=["GET", "POST"])
-async def admin_keys_update() -> str | response.Response | tuple[Mapping[str, 
Any], int]:
[email protected]("/keys/update")
+async def keys_update_get(session: session.Committer) -> str | 
response.Response | tuple[Mapping[str, Any], int]:
+    return await _keys_update(session)
+
+
[email protected]("/keys/update")
+async def keys_update_post(session: session.Committer) -> str | 
response.Response | tuple[Mapping[str, Any], int]:
+    return await _keys_update(session)
+
+
+async def _keys_update(session: session.Committer) -> str | response.Response 
| tuple[Mapping[str, Any], int]:
     """Update keys from remote data."""
     if quart.request.method != "POST":
         empty_form = await forms.Empty.create_form()
@@ -478,13 +530,7 @@ async def admin_keys_update() -> str | response.Response | 
tuple[Mapping[str, An
         return await template.render("update-keys.html", 
empty_form=empty_form, previous_output=previous_output)
 
     try:
-        web_session = await session.read()
-        if web_session is None:
-            raise base.ASFQuartException("Not authenticated", 401)
-        asf_uid = web_session.uid
-        if asf_uid is None:
-            raise base.ASFQuartException("Invalid session, uid is None", 500)
-        pid = await _update_keys(asf_uid)
+        pid = await _update_keys(session.asf_uid)
         return {
             "message": f"Successfully started key update process with PID 
{pid}",
             "category": "success",
@@ -497,14 +543,18 @@ async def admin_keys_update() -> str | response.Response 
| tuple[Mapping[str, An
         }, 200
 
 
[email protected]("/ldap/", methods=["GET"])
-async def admin_ldap() -> str:
-    form = await LdapLookupForm.create_form(data=quart.request.args)
-    asf_id_for_template: str | None = None
[email protected]("/ldap/")
+async def ldap_get(session: session.Committer) -> str:
+    return await _ldap(session)
+
 
-    web_session = await session.read()
-    if web_session and web_session.uid:
-        asf_id_for_template = web_session.uid
[email protected]("/ldap/")
+async def ldap_post(session: session.Committer) -> str:
+    return await _ldap(session)
+
+
+async def _ldap(session: session.Committer) -> str:
+    form = await LdapLookupForm.create_form(data=quart.request.args)
 
     uid_query = form.uid.data
     email_query = form.email.data
@@ -530,14 +580,29 @@ async def admin_ldap() -> str:
         "ldap-lookup.html",
         form=form,
         ldap_params=ldap_params,
-        asf_id=asf_id_for_template,
+        asf_id=session.asf_uid,
         ldap_query_performed=ldap_params is not None,
         uid_query=uid_query,
     )
 
 
[email protected]("/ongoing-tasks/<project_name>/<version_name>/<revision>")
-async def admin_ongoing_tasks(project_name: str, version_name: str, revision: 
str) -> quart.wrappers.response.Response:
[email protected]("/ongoing-tasks/<project_name>/<version_name>/<revision>")
+async def ongoing_tasks_get(
+    session: session.Committer, project_name: str, version_name: str, 
revision: str
+) -> quart.wrappers.response.Response:
+    return await _ongoing_tasks(session, project_name, version_name, revision)
+
+
[email protected]("/ongoing-tasks/<project_name>/<version_name>/<revision>")
+async def ongoing_tasks_post(
+    session: session.Committer, project_name: str, version_name: str, 
revision: str
+) -> quart.wrappers.response.Response:
+    return await _ongoing_tasks(session, project_name, version_name, revision)
+
+
+async def _ongoing_tasks(
+    session: session.Committer, project_name: str, version_name: str, 
revision: str
+) -> quart.wrappers.response.Response:
     try:
         ongoing = await interaction.tasks_ongoing(project_name, version_name, 
revision)
         return quart.Response(str(ongoing), mimetype="text/plain")
@@ -546,8 +611,8 @@ async def admin_ongoing_tasks(project_name: str, 
version_name: str, revision: st
         return quart.Response("", mimetype="text/plain")
 
 
[email protected]("/performance")
-async def admin_performance() -> str:
[email protected]("/performance")
+async def performance(session: session.Committer) -> str:
     """Display performance statistics for all routes."""
     app = asfquart.APP
 
@@ -627,19 +692,21 @@ async def admin_performance() -> str:
     return await template.render("performance.html", stats=sorted_summary)
 
 
[email protected]("/projects/update", methods=["GET", "POST"])
-async def admin_projects_update() -> str | response.Response | 
tuple[Mapping[str, Any], int]:
[email protected]("/projects/update")
+async def projects_update_get(session: session.Committer) -> str | 
response.Response | tuple[Mapping[str, Any], int]:
+    return await _projects_update(session)
+
+
[email protected]("/projects/update")
+async def projects_update_post(session: session.Committer) -> str | 
response.Response | tuple[Mapping[str, Any], int]:
+    return await _projects_update(session)
+
+
+async def _projects_update(session: session.Committer) -> str | 
response.Response | tuple[Mapping[str, Any], int]:
     """Update projects from remote data."""
     if quart.request.method == "POST":
         try:
-            web_session = await session.read()
-            if web_session is None:
-                raise base.ASFQuartException("Not authenticated", 401)
-            asf_uid = web_session.uid
-            if asf_uid is None:
-                raise base.ASFQuartException("Invalid session, uid is None", 
500)
-
-            task = await tasks.metadata_update(asf_uid)
+            task = await tasks.metadata_update(session.asf_uid)
             return {
                 "message": f"Metadata update task has been queued with ID 
{task.id}.",
                 "category": "success",
@@ -656,14 +723,14 @@ async def admin_projects_update() -> str | 
response.Response | tuple[Mapping[str
     return await template.render("update-projects.html", empty_form=empty_form)
 
 
[email protected]("/tasks")
-async def admin_tasks() -> str:
[email protected]("/tasks")
+async def tasks_(session: session.Committer) -> str:
     return await template.render("tasks.html")
 
 
[email protected]("/task-times/<project_name>/<version_name>/<revision_number>")
-async def admin_task_times(
-    project_name: str, version_name: str, revision_number: str
[email protected]("/task-times/<project_name>/<version_name>/<revision_number>")
+async def task_times(
+    session: session.Committer, project_name: str, version_name: str, 
revision_number: str
 ) -> quart.wrappers.response.Response:
     values = []
     async with db.session() as data:
@@ -679,8 +746,8 @@ async def admin_task_times(
     return quart.Response("\n".join(values), mimetype="text/plain")
 
 
[email protected]("/test", methods=["GET"])
-async def admin_test() -> quart.wrappers.response.Response:
[email protected]("/test")
+async def test(session: session.Committer) -> quart.wrappers.response.Response:
     """Test the storage layer."""
     import atr.storage as storage
 
@@ -689,13 +756,7 @@ async def admin_test() -> quart.wrappers.response.Response:
         async with aiohttp_client_session.get(url) as response:
             keys_file_text = await response.text()
 
-    web_session = await session.read()
-    if web_session is None:
-        raise base.ASFQuartException("Not authenticated", 401)
-    asf_uid = web_session.uid
-    if asf_uid is None:
-        raise base.ASFQuartException("Invalid session, uid is None", 500)
-    async with storage.write() as write:
+    async with storage.write(session) as write:
         wacm = write.as_committee_member("tooling")
         start = time.perf_counter_ns()
         outcomes: outcome.List[types.Key] = await 
wacm.keys.ensure_stored(keys_file_text)
@@ -718,26 +779,17 @@ async def admin_test() -> 
quart.wrappers.response.Response:
     return quart.Response(str(wacm), mimetype="text/plain")
 
 
[email protected]("/toggle-view", methods=["GET"])
-async def admin_toggle_admin_view_page() -> str:
[email protected]("/toggle-view")
+async def toggle_view_get(session: session.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)
 
 
[email protected]("/toggle-admin-view", methods=["POST"])
-async def admin_toggle_view() -> response.Response:
[email protected]("/toggle-view")
+async def toggle_view_post(session: session.Committer) -> response.Response:
     await util.validate_empty_form()
 
-    web_session = await session.read()
-    if web_session is None:
-        # For the type checker
-        # We should pass this as an argument, then it's guaranteed
-        raise base.ASFQuartException("Not authenticated", 401)
-    user_uid = web_session.uid
-    if user_uid is None:
-        raise base.ASFQuartException("Invalid session, uid is None", 500)
-
     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")
@@ -750,11 +802,11 @@ async def admin_toggle_view() -> response.Response:
     message = "Viewing as regular user" if downgrade else "Viewing as admin"
     await quart.flash(message, "success")
     referrer = quart.request.referrer
-    return quart.redirect(referrer or quart.url_for("admin.admin_data"))
+    return quart.redirect(referrer or util.as_url(data))
 
 
[email protected]("/validate")
-async def admin_validate() -> str:
[email protected]("/validate")
+async def validate_(session: session.Committer) -> str:
     """Run validators and display any divergences."""
 
     async with db.session() as data:
@@ -789,7 +841,7 @@ async def _check_keys(fix: bool = False) -> str:
     return message
 
 
-async def _delete_releases(session: route.CommitterSession, 
releases_to_delete: list[str]) -> None:
+async def _delete_releases(session: session.Committer, releases_to_delete: 
list[str]) -> None:
     success_count = 0
     fail_count = 0
     error_messages = []
@@ -881,7 +933,7 @@ def _get_user_committees_from_ldap(uid: str, bind_dn: str, 
bind_password: str) -
 def _session_data(
     ldap_data: dict[str, Any],
     new_uid: str,
-    current_session: session.ClientSession,
+    current_session: asfquart.session.ClientSession,
     ldap_projects: apache.LDAPProjectsData,
     committee_data: apache.CommitteeData,
     bind_dn: str,
diff --git a/atr/bps/admin/templates/all-releases.html 
b/atr/admin/templates/all-releases.html
similarity index 100%
rename from atr/bps/admin/templates/all-releases.html
rename to atr/admin/templates/all-releases.html
diff --git a/atr/bps/admin/templates/browse-as.html 
b/atr/admin/templates/browse-as.html
similarity index 100%
rename from atr/bps/admin/templates/browse-as.html
rename to atr/admin/templates/browse-as.html
diff --git a/atr/bps/admin/templates/data-browser.html 
b/atr/admin/templates/data-browser.html
similarity index 97%
rename from atr/bps/admin/templates/data-browser.html
rename to atr/admin/templates/data-browser.html
index be0ec1d..5337682 100644
--- a/atr/bps/admin/templates/data-browser.html
+++ b/atr/admin/templates/data-browser.html
@@ -70,7 +70,7 @@
 
   <div class="page-model-nav">
     {% for model_name in models %}
-      <a href="{{ url_for('admin.admin_data', model=model_name) }}"
+      <a href="{{ as_url(admin.data, model=model_name) }}"
          {% if model == model_name %}class="active"{% endif %}>{{ model_name 
}}</a>
     {% endfor %}
   </div>
diff --git a/atr/bps/admin/templates/delete-release.html 
b/atr/admin/templates/delete-release.html
similarity index 100%
rename from atr/bps/admin/templates/delete-release.html
rename to atr/admin/templates/delete-release.html
diff --git a/atr/bps/admin/templates/ldap-lookup.html 
b/atr/admin/templates/ldap-lookup.html
similarity index 98%
rename from atr/bps/admin/templates/ldap-lookup.html
rename to atr/admin/templates/ldap-lookup.html
index 41e6152..ceff544 100644
--- a/atr/bps/admin/templates/ldap-lookup.html
+++ b/atr/admin/templates/ldap-lookup.html
@@ -17,7 +17,7 @@
       <h5 class="mb-0">Search criteria</h5>
     </div>
     <div class="card-body">
-      <form method="get" action="{{ url_for('admin.admin_ldap') }}">
+      <form method="get" action="{{ as_url(admin.ldap_get) }}">
         {{ form.csrf_token }}
         <div class="mb-3">
           {{ form.uid.label(class="form-label") }}
diff --git a/atr/bps/admin/templates/performance.html 
b/atr/admin/templates/performance.html
similarity index 100%
rename from atr/bps/admin/templates/performance.html
rename to atr/admin/templates/performance.html
diff --git a/atr/bps/admin/templates/tasks.html b/atr/admin/templates/tasks.html
similarity index 100%
rename from atr/bps/admin/templates/tasks.html
rename to atr/admin/templates/tasks.html
diff --git a/atr/bps/admin/templates/toggle-admin-view.html 
b/atr/admin/templates/toggle-admin-view.html
similarity index 93%
rename from atr/bps/admin/templates/toggle-admin-view.html
rename to atr/admin/templates/toggle-admin-view.html
index 28e87bd..b39910b 100644
--- a/atr/bps/admin/templates/toggle-admin-view.html
+++ b/atr/admin/templates/toggle-admin-view.html
@@ -15,7 +15,7 @@
   </p>
 
   {% if current_user and is_admin_fn(current_user.uid) %}
-    <form action="{{ url_for('admin.admin_toggle_view') }}" method="post" 
class="mb-4">
+    <form action="{{ as_url(admin.toggle_view_post) }}" method="post" 
class="mb-4">
       {{ empty_form.hidden_tag() }}
 
       <button type="submit" class="btn btn-primary">
diff --git a/atr/bps/admin/templates/update-keys.html 
b/atr/admin/templates/update-keys.html
similarity index 100%
rename from atr/bps/admin/templates/update-keys.html
rename to atr/admin/templates/update-keys.html
diff --git a/atr/bps/admin/templates/update-projects.html 
b/atr/admin/templates/update-projects.html
similarity index 100%
rename from atr/bps/admin/templates/update-projects.html
rename to atr/admin/templates/update-projects.html
diff --git a/atr/bps/admin/templates/validation.html 
b/atr/admin/templates/validation.html
similarity index 100%
rename from atr/bps/admin/templates/validation.html
rename to atr/admin/templates/validation.html
diff --git a/atr/blueprints/__init__.py b/atr/blueprints/__init__.py
index b72501b..7d149a8 100644
--- a/atr/blueprints/__init__.py
+++ b/atr/blueprints/__init__.py
@@ -20,6 +20,7 @@ from typing import Protocol, runtime_checkable
 
 import asfquart.base as base
 
+import atr.blueprints.admin as admin
 import atr.blueprints.api as api
 import atr.blueprints.get as get
 
@@ -38,5 +39,6 @@ def check_module(module: ModuleType) -> None:
 
 
 def register(app: base.QuartApp) -> None:
+    check_module(admin.register(app))
     check_module(api.register(app))
     check_module(get.register(app))
diff --git a/atr/blueprints/get.py b/atr/blueprints/admin.py
similarity index 51%
copy from atr/blueprints/get.py
copy to atr/blueprints/admin.py
index 460644c..ad263fe 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/admin.py
@@ -15,83 +15,68 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import time
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable
 from types import ModuleType
 from typing import Any
 
-import asfquart.auth as auth
 import asfquart.base as base
 import asfquart.session
 import quart
 
-import atr.log as log
 import atr.session as session
+import atr.user as user
 
-_BLUEPRINT = quart.Blueprint("get_blueprint", __name__)
+_BLUEPRINT_NAME = "admin_blueprint"
+_BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__, url_prefix="/admin", 
template_folder="../admin/templates")
+
+
+@_BLUEPRINT.before_request
+async def _check_admin_access() -> None:
+    web_session = await asfquart.session.read()
+    if web_session is None:
+        raise base.ASFQuartException("Not authenticated", errorcode=401)
+
+    if web_session.uid not in user.get_admin_users():
+        raise base.ASFQuartException("You are not authorized to access the 
admin interface", errorcode=403)
+
+    quart.g.session = session.Committer(web_session)
 
 
 def register(app: base.QuartApp) -> ModuleType:
-    import atr.get as get
+    import atr.admin as admin
 
     app.register_blueprint(_BLUEPRINT)
-    return get
+    return admin
 
 
-def committer(path: str) -> Callable[[session.CommitterRouteFunction[Any]], 
session.RouteFunction[Any]]:
+def get(path: str) -> Callable[[session.CommitterRouteFunction[Any]], 
session.RouteFunction[Any]]:
     def decorator(func: session.CommitterRouteFunction[Any]) -> 
session.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            if web_session is None:
-                raise base.ASFQuartException("Not authenticated", 
errorcode=401)
-
-            enhanced_session = session.Committer(web_session)
-            start_time_ns = time.perf_counter_ns()
-            response = await func(enhanced_session, *args, **kwargs)
-            end_time_ns = time.perf_counter_ns()
-            total_ns = end_time_ns - start_time_ns
-            total_ms = total_ns // 1_000_000
-
-            # TODO: Make this configurable in config.py
-            log.performance(
-                "%s %s %s %s %s %s %s",
-                "GET",
-                path,
-                func.__name__,
-                "=",
-                0,
-                0,
-                total_ms,
-            )
-
-            return response
+            return await func(quart.g.session, *args, **kwargs)
 
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
         wrapper.__name__ = func.__name__
         wrapper.__doc__ = func.__doc__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
-        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
-
-        decorated = auth.require(auth.Requirements.committer)(wrapper)
-        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["GET"])
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["GET"])
 
-        return decorated
+        return wrapper
 
     return decorator
 
 
-def public(path: str) -> Callable[[Callable[..., Awaitable[Any]]], 
session.RouteFunction[Any]]:
-    def decorator(func: Callable[..., Awaitable[Any]]) -> 
session.RouteFunction[Any]:
+def post(path: str) -> Callable[[session.CommitterRouteFunction[Any]], 
session.RouteFunction[Any]]:
+    def decorator(func: session.CommitterRouteFunction[Any]) -> 
session.RouteFunction[Any]:
         async def wrapper(*args: Any, **kwargs: Any) -> Any:
-            web_session = await asfquart.session.read()
-            enhanced_session = session.Committer(web_session) if web_session 
else None
-            return await func(enhanced_session, *args, **kwargs)
+            return await func(quart.g.session, *args, **kwargs)
 
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
         wrapper.__name__ = func.__name__
         wrapper.__doc__ = func.__doc__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
-        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
-
-        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["GET"])
+        _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["POST"])
 
         return wrapper
 
diff --git a/atr/blueprints/get.py b/atr/blueprints/get.py
index 460644c..7567d8e 100644
--- a/atr/blueprints/get.py
+++ b/atr/blueprints/get.py
@@ -28,7 +28,8 @@ import quart
 import atr.log as log
 import atr.session as session
 
-_BLUEPRINT = quart.Blueprint("get_blueprint", __name__)
+_BLUEPRINT_NAME = "get_blueprint"
+_BLUEPRINT = quart.Blueprint(_BLUEPRINT_NAME, __name__)
 
 
 def register(app: base.QuartApp) -> ModuleType:
@@ -66,10 +67,10 @@ def committer(path: str) -> 
Callable[[session.CommitterRouteFunction[Any]], sess
 
             return response
 
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
         wrapper.__name__ = func.__name__
         wrapper.__doc__ = func.__doc__
-
-        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
         decorated = auth.require(auth.Requirements.committer)(wrapper)
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=decorated, 
methods=["GET"])
@@ -86,10 +87,10 @@ def public(path: str) -> Callable[[Callable[..., 
Awaitable[Any]]], session.Route
             enhanced_session = session.Committer(web_session) if web_session 
else None
             return await func(enhanced_session, *args, **kwargs)
 
+        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
         wrapper.__name__ = func.__name__
         wrapper.__doc__ = func.__doc__
-
-        endpoint = func.__module__.replace(".", "_") + "_" + func.__name__
+        wrapper.__annotations__["endpoint"] = _BLUEPRINT_NAME + "." + endpoint
 
         _BLUEPRINT.add_url_rule(path, endpoint=endpoint, view_func=wrapper, 
methods=["GET"])
 
diff --git a/atr/bps/__init__.py b/atr/bps/__init__.py
index 25f10ae..1fc08c4 100644
--- a/atr/bps/__init__.py
+++ b/atr/bps/__init__.py
@@ -19,8 +19,6 @@ import asfquart.base as base
 
 
 def register(app: base.QuartApp) -> None:
-    import atr.bps.admin.admin as admin
     import atr.bps.icons as icons
 
-    app.register_blueprint(admin.admin.BLUEPRINT)
     app.register_blueprint(icons.BLUEPRINT)
diff --git a/atr/bps/admin/__init__.py b/atr/bps/admin/__init__.py
deleted file mode 100644
index f3a992e..0000000
--- a/atr/bps/admin/__init__.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-"""Any routes related to the admin interface of the ATR."""
-
-from typing import Final
-
-import asfquart.auth as auth
-import asfquart.base as base
-import asfquart.session as session
-import quart
-
-import atr.user as user
-
-BLUEPRINT: Final = quart.Blueprint("admin", __name__, url_prefix="/admin", 
template_folder="templates")
-
-
[email protected]_request
-async def before_request_func() -> None:
-    @auth.require(auth.Requirements.committer)
-    async def check_logged_in() -> None:
-        web_session = await session.read()
-        if web_session is None:
-            raise base.ASFQuartException("Not authenticated", errorcode=401)
-
-        if web_session.uid not in user.get_admin_users():
-            raise base.ASFQuartException("You are not authorized to access the 
admin interface", errorcode=403)
-
-    await check_logged_in()
diff --git a/atr/principal.py b/atr/principal.py
index 72cc894..73d3829 100644
--- a/atr/principal.py
+++ b/atr/principal.py
@@ -22,12 +22,14 @@ import re
 import time
 from typing import Any, Final
 
-import asfquart.session as session
+import asfquart.session
 
+# from attr import asdict
 import atr.config as config
 import atr.ldap as ldap
 import atr.log as log
 import atr.route as route
+import atr.session as session
 import atr.util as util
 
 LDAP_CHAIRS_BASE = "cn=pmc-chairs,ou=groups,ou=services,dc=apache,dc=org"
@@ -59,7 +61,7 @@ class ArgumentNoneType:
 
 ArgumentNone = ArgumentNoneType()
 
-type UID = route.CommitterSession | str | None | ArgumentNoneType
+type UID = route.CommitterSession | session.Committer | str | None | 
ArgumentNoneType
 
 
 def attr_to_list(attr):
@@ -256,10 +258,10 @@ class AuthoriserASFQuart:
     def member_of_and_participant_of(self, asf_uid: str) -> 
tuple[frozenset[str], frozenset[str]]:
         return self.__cache.member_of[asf_uid], 
self.__cache.participant_of[asf_uid]
 
-    async def cache_refresh(self, asf_uid: str, asfquart_session: 
session.ClientSession) -> None:
+    async def cache_refresh(self, asf_uid: str, asfquart_session: 
asfquart.session.ClientSession) -> None:
         if not self.__cache.outdated(asf_uid):
             return
-        if not isinstance(asfquart_session, session.ClientSession):
+        if not isinstance(asfquart_session, asfquart.session.ClientSession):
             # Defense in depth runtime check, already validated by the type 
checker
             raise AuthenticationError("ASFQuart session is not a 
ClientSession")
 
@@ -352,12 +354,12 @@ class AsyncObject:
 class Authorisation(AsyncObject):
     async def __init__(self, asf_uid: UID = ArgumentNone):
         match asf_uid:
-            case ArgumentNoneType() | route.CommitterSession():
+            case ArgumentNoneType() | route.CommitterSession() | 
session.Committer():
                 match asf_uid:
-                    case route.CommitterSession():
+                    case route.CommitterSession() | session.Committer():
                         asfquart_session = asf_uid._session
                     case _:
-                        asfquart_session = await session.read()
+                        asfquart_session = await asfquart.session.read()
                 # asfquart_session = await session.read()
                 if asfquart_session is None:
                     raise AuthenticationError("No ASFQuart session found")
diff --git a/atr/server.py b/atr/server.py
index b827078..0251064 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -128,11 +128,13 @@ def app_setup_context(app: base.QuartApp) -> None:
 
     @app.context_processor
     async def app_wide() -> dict[str, Any]:
+        import atr.admin as admin
         import atr.metadata as metadata
         import atr.routes as routes
         import atr.routes.mapping as mapping
 
         return {
+            "admin": admin,
             "as_url": util.as_url,
             "commit": metadata.commit,
             "current_user": await asfquart.session.read(),
diff --git a/atr/templates/delete-committee-keys.html 
b/atr/templates/delete-committee-keys.html
index 3d33141..61e7611 100644
--- a/atr/templates/delete-committee-keys.html
+++ b/atr/templates/delete-committee-keys.html
@@ -11,7 +11,7 @@
     <h1 class="mb-4">Delete all keys for a committee</h1>
 
     <form method="post"
-          action="{{ url_for('admin.admin_delete_committee_keys') }}"
+          action="{{ as_url(admin.delete_committee_keys_post) }}"
           class="atr-canary py-4 px-5 border rounded">
       {{ form.csrf_token }}
 
diff --git a/atr/templates/includes/sidebar.html 
b/atr/templates/includes/sidebar.html
index 55ac2fc..ef2a63a 100644
--- a/atr/templates/includes/sidebar.html
+++ b/atr/templates/includes/sidebar.html
@@ -138,83 +138,83 @@
         <ul>
           <li>
             <i class="bi bi-list-ul"></i>
-            <a href="{{ url_for('admin.admin_all_releases') }}"
-               {% if request.endpoint == 'admin.admin_all_releases' 
%}class="active"{% endif %}>All releases</a>
+            <a href="{{ as_url(admin.all_releases) }}"
+               {% if request.endpoint == 'atr_admin_all_releases' 
%}class="active"{% endif %}>All releases</a>
           </li>
           <li>
             <i class="bi bi-person-plus"></i>
-            <a href="{{ url_for('admin.admin_browse_as') }}"
-               {% if request.endpoint == 'admin.admin_browse_as' 
%}class="active"{% endif %}>Browse as user</a>
+            <a href="{{ as_url(admin.browse_as_get) }}"
+               {% if request.endpoint == 'atr_admin_browse_as_get' 
%}class="active"{% endif %}>Browse as user</a>
           </li>
           <li>
             <i class="bi bi-arrow-repeat"></i>
-            <a href="{{ url_for('admin.admin_consistency') }}"
-               {% if request.endpoint == 'admin.admin_consistency' 
%}class="active"{% endif %}>Consistency</a>
+            <a href="{{ as_url(admin.consistency) }}"
+               {% if request.endpoint == 'atr_admin_consistency' 
%}class="active"{% endif %}>Consistency</a>
           </li>
           <li>
             <i class="bi bi-database"></i>
-            <a href="{{ url_for('admin.admin_data') }}"
-               {% if request.endpoint == 'admin.admin_data' %}class="active"{% 
endif %}>Browse database</a>
+            <a href="{{ as_url(admin.data) }}"
+               {% if request.endpoint == 'atr_admin_data' %}class="active"{% 
endif %}>Browse database</a>
           </li>
           <li>
             <i class="bi bi-trash"></i>
-            <a href="{{ url_for('admin.admin_delete_committee_keys') }}"
-               {% if request.endpoint == 'admin.admin_delete_committee_keys' 
%}class="active"{% endif %}>Delete committee keys</a>
+            <a href="{{ as_url(admin.delete_committee_keys_get) }}"
+               {% if request.endpoint == 'atr_admin_delete_committee_keys_get' 
%}class="active"{% endif %}>Delete committee keys</a>
           </li>
           <li>
             <i class="bi bi-trash"></i>
-            <a href="{{ url_for('admin.admin_delete_release') }}"
-               {% if request.endpoint == 'admin.admin_delete_release' 
%}class="active"{% endif %}>Delete release</a>
+            <a href="{{ as_url(admin.delete_release_get) }}"
+               {% if request.endpoint == 'atr_admin_delete_release_get' 
%}class="active"{% endif %}>Delete release</a>
           </li>
           <li>
             <i class="bi bi-gear"></i>
-            <a href="{{ url_for('admin.admin_env') }}"
-               {% if request.endpoint == 'admin.admin_env' %}class="active"{% 
endif %}>Environment</a>
+            <a href="{{ as_url(admin.env) }}"
+               {% if request.endpoint == 'atr_admin_env' %}class="active"{% 
endif %}>Environment</a>
           </li>
           <li>
             <i class="bi bi-key"></i>
-            <a href="{{ url_for('admin.admin_keys_check') }}"
-               {% if request.endpoint == 'admin.admin_keys_check' 
%}class="active"{% endif %}>Keys check</a>
+            <a href="{{ as_url(admin.keys_check_get) }}"
+               {% if request.endpoint == 'atr_admin_keys_check_get' 
%}class="active"{% endif %}>Keys check</a>
           </li>
           <li>
             <i class="bi bi-key"></i>
-            <a href="{{ url_for('admin.admin_keys_regenerate_all') }}"
-               {% if request.endpoint == 'admin.admin_keys_regenerate_all' 
%}class="active"{% endif %}>Regenerate all keys</a>
+            <a href="{{ as_url(admin.keys_regenerate_all_get) }}"
+               {% if request.endpoint == 'atr_admin_keys_regenerate_all_get' 
%}class="active"{% endif %}>Regenerate all keys</a>
           </li>
           <li>
             <i class="bi bi-key"></i>
-            <a href="{{ url_for('admin.admin_keys_update') }}"
-               {% if request.endpoint == 'admin.admin_keys_update' 
%}class="active"{% endif %}>Update keys</a>
+            <a href="{{ as_url(admin.keys_update_get) }}"
+               {% if request.endpoint == 'atr_admin_keys_update_get' 
%}class="active"{% endif %}>Update keys</a>
           </li>
           <li>
             <i class="bi bi-person-plus"></i>
-            <a href="{{ url_for('admin.admin_ldap') }}"
-               {% if request.endpoint == 'admin.admin_ldap' %}class="active"{% 
endif %}>LDAP search</a>
+            <a href="{{ as_url(admin.ldap_get) }}"
+               {% if request.endpoint == 'atr_admin_ldap_get' 
%}class="active"{% endif %}>LDAP search</a>
           </li>
           <li>
             <i class="bi bi-speedometer2"></i>
-            <a href="{{ url_for('admin.admin_performance') }}"
-               {% if request.endpoint == 'admin.admin_performance' 
%}class="active"{% endif %}>Page performance</a>
+            <a href="{{ as_url(admin.performance) }}"
+               {% if request.endpoint == 'atr_admin_performance' 
%}class="active"{% endif %}>Page performance</a>
           </li>
           <li>
             <i class="bi bi-arrow-repeat"></i>
-            <a href="{{ url_for('admin.admin_projects_update') }}"
-               {% if request.endpoint == 'admin.admin_projects_update' 
%}class="active"{% endif %}>Update projects</a>
+            <a href="{{ as_url(admin.projects_update_get) }}"
+               {% if request.endpoint == 'atr_admin_projects_update_get' 
%}class="active"{% endif %}>Update projects</a>
           </li>
           <li>
             <i class="bi bi-list-task"></i>
-            <a href="{{ url_for('admin.admin_tasks') }}"
-               {% if request.endpoint == 'admin.admin_tasks' 
%}class="active"{% endif %}>Background tasks</a>
+            <a href="{{ as_url(admin.tasks_) }}"
+               {% if request.endpoint == 'atr_admin_tasks_' %}class="active"{% 
endif %}>Background tasks</a>
           </li>
           <li>
             <i class="bi bi-person-badge"></i>
-            <a href="{{ url_for('admin.admin_toggle_admin_view_page') }}"
-               {% if request.endpoint == 'admin.admin_toggle_admin_view_page' 
%}class="active"{% endif %}>Toggle admin view</a>
+            <a href="{{ as_url(admin.toggle_view_get) }}"
+               {% if request.endpoint == 'atr_admin_toggle_view_get' 
%}class="active"{% endif %}>Toggle admin view</a>
           </li>
           <li>
             <i class="bi bi-arrow-repeat"></i>
-            <a href="{{ url_for('admin.admin_validate') }}"
-               {% if request.endpoint == 'admin.admin_validate' 
%}class="active"{% endif %}>Validate</a>
+            <a href="{{ as_url(admin.validate_) }}"
+               {% if request.endpoint == 'atr_admin_validate_' 
%}class="active"{% endif %}>Validate</a>
           </li>
         </ul>
       {% endif %}


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

Reply via email to