Coverage for src/asgi_profiler/contrib/sqladmin.py: 96%
61 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-22 16:59 +0000
1"""Render the profiler as a view inside SQLAdmin instead of its own page.
3 from asgi_profiler import install
4 from asgi_profiler.contrib.sqladmin import register
6 admin = Admin(app, engine)
7 profiler = install(app, mount_path="/profiler")
8 register(admin, profiler)
10Why this is worth having rather than just mounting the standalone viewer next
11to the admin: SQLAdmin's `expose` wraps every route in its authentication and
12accessibility checks. The standalone viewer has no authentication of its own --
13that is the loudest warning in its README -- so putting it behind an admin the
14user already had to log into is a real improvement, not just a cosmetic one.
16The templates extend `sqladmin/layout.html` and use Tabler markup, so the pages
17arrive with the admin's sidebar, theme and navigation rather than in an iframe
18wearing a different stylesheet.
19"""
21from __future__ import annotations
23from typing import TYPE_CHECKING, Any
25from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader
26from starlette.responses import RedirectResponse
28from ..detectors import LABELS, SEVERITY
29from ..models import group_queries
30from ..storage import Filters
32#: The viewer's own severity names translated into Tabler's palette, which is
33#: what SQLAdmin's layout understands. Keyed by problem type, so the template
34#: asks the same question here as it does on the standalone pages.
35_TABLER = {
36 problem_type: {"err": "red", "warn": "orange", "info": "azure"}.get(name, "orange")
37 for problem_type, name in SEVERITY.items()
38}
40if TYPE_CHECKING: # pragma: no cover
41 from starlette.requests import Request
43 from .. import Profiler
45#: Our templates are namespaced so they cannot collide with a template the
46#: application or SQLAdmin already has by that name.
47_PREFIX = "asgi_profiler"
50def _install_loader(templates: Any) -> None:
51 """Teach SQLAdmin's Jinja environment where our templates live.
53 SQLAdmin builds a `ChoiceLoader` over the user's directory and its own
54 package. Ours has to be appended, or `extends "sqladmin/layout.html"`
55 resolves and our own template name does not.
56 """
57 ours = PrefixLoader({_PREFIX: PackageLoader("asgi_profiler", "templates/sqladmin")})
58 loader = templates.env.loader
59 if isinstance(loader, ChoiceLoader):
60 if not any( 60 ↛ exitline 60 didn't return from function '_install_loader' because the condition on line 60 was always true
61 isinstance(existing, PrefixLoader) and _PREFIX in existing.mapping
62 for existing in loader.loaders
63 ):
64 loader.loaders.append(ours)
65 else: # pragma: no cover - SQLAdmin has always used a ChoiceLoader
66 templates.env.loader = ChoiceLoader([loader, ours])
69def register(
70 admin: Any,
71 profiler: Profiler,
72 *,
73 name: str = "Profiler",
74 icon: str = "fa-solid fa-gauge-high",
75 category: str = "",
76 profile_admin: bool = False,
77) -> type:
78 """Add the profiler to `admin` and return the generated view class.
80 Args:
81 admin: a `sqladmin.Admin` instance.
82 profiler: the handle returned by :func:`asgi_profiler.install`.
83 name, icon, category: how the entry appears in SQLAdmin's sidebar.
84 profile_admin: also record requests to the admin itself. Off by
85 default, and rarely worth turning on: SQLAdmin builds its own
86 queries and they are already tuned, so recording them buries your
87 application's requests under admin traffic. It is off rather than
88 merely discouraged because the profiler instruments SQLAlchemy's
89 `Engine` class and SQLAdmin is a SQLAlchemy application, so with it
90 on, opening the profiler records the profiler -- every visit adding
91 its own pages to the history you came to read.
93 Turn it on when the admin itself is what you are debugging: a slow
94 `ModelView`, an expensive `column_list`, a custom view of your own.
95 """
96 from sqladmin import BaseView, expose # imported late: an optional extra
98 if not profile_admin:
99 profiler.exclude(admin.base_url)
101 _install_loader(admin.templates)
102 storage = profiler.storage
103 config = profiler.config
105 #: SQLAdmin names an exposed route `view-<identity>` and mounts its
106 #: sub-application under `admin`, so the list route resolves as
107 #: `admin:view-profiler`. That is its internal convention rather than a
108 #: documented API, hence the fallback: if the name ever changes the links
109 #: keep working off `base_url` instead of the view 500ing.
110 route_name = "admin:view-profiler"
111 fallback_base = admin.base_url.rstrip("/") + "/profiler"
113 def base_url(request: Request) -> str:
114 try:
115 return str(request.url_for(route_name))
116 except Exception: # pragma: no cover - only if SQLAdmin renames routes
117 return fallback_base
119 # Bound late, because the class is not defined yet where `context` is used.
120 view_name, view_icon, view_category = name, icon, category
122 class ProfilerView(BaseView): # type: ignore[misc, valid-type]
123 # Assigned in the class body rather than onto the class afterwards:
124 # a type checker cannot see attributes bolted on later, and neither can
125 # anyone reading this.
126 name = view_name
127 icon = view_icon
128 category = view_category
129 identity = "profiler"
131 def context(self, request: Request) -> dict[str, Any]:
132 # `view` is what the sidebar highlights; `base` is our URL root, so
133 # the templates never hard-code where SQLAdmin mounted us.
134 return {
135 "view": type(self),
136 "base": base_url(request),
137 "config": config,
138 "problem_labels": LABELS,
139 "problem_severity": _TABLER,
140 }
142 @expose("/profiler", methods=["GET"], identity="profiler")
143 async def requests_page(self, request: Request) -> Any:
144 filters = Filters.from_params(request.query_params)
145 number = _int(request.query_params.get("page"), 1)
146 page = storage.search(filters, page=number, size=config.page_size)
147 return await self.templates.TemplateResponse(
148 request,
149 f"{_PREFIX}/requests.html",
150 {**self.context(request), "page": page, "filters": filters},
151 )
153 @expose("/profiler/request/{profile_id}", methods=["GET"])
154 async def detail_page(self, request: Request) -> Any:
155 profile = storage.get(request.path_params["profile_id"])
156 if profile is None:
157 # Through the same resolver as the templates. Calling `url_for`
158 # directly here is what made a missing profile a 500 rather
159 # than the redirect it is supposed to be.
160 return RedirectResponse(base_url(request), 303)
161 return await self.templates.TemplateResponse(
162 request,
163 f"{_PREFIX}/detail.html",
164 {
165 **self.context(request),
166 "profile": profile,
167 "groups": group_queries(profile.queries),
168 },
169 )
171 @expose("/profiler/summary", methods=["GET"])
172 async def summary_page(self, request: Request) -> Any:
173 return await self.templates.TemplateResponse(
174 request,
175 f"{_PREFIX}/summary.html",
176 {**self.context(request), "rows": storage.summarise()},
177 )
179 @expose("/profiler/clear", methods=["POST"])
180 async def clear(self, request: Request) -> Any:
181 """Discard the recorded history.
183 POST, and a form rather than a fetch: SQLAdmin carries no CSRF
184 token of its own, so a hand-rolled one here would protect nothing
185 the rest of the admin does not already expose. `expose` has already
186 put this behind the admin's authentication.
187 """
188 storage.clear()
189 return RedirectResponse(base_url(request), 303)
191 admin.add_base_view(ProfilerView)
192 return ProfilerView
195def _int(raw: str | None, default: int) -> int:
196 try:
197 return max(1, int(raw or default))
198 except (TypeError, ValueError):
199 return default