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

1"""Render the profiler as a view inside SQLAdmin instead of its own page. 

2 

3 from asgi_profiler import install 

4 from asgi_profiler.contrib.sqladmin import register 

5 

6 admin = Admin(app, engine) 

7 profiler = install(app, mount_path="/profiler") 

8 register(admin, profiler) 

9 

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. 

15 

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""" 

20 

21from __future__ import annotations 

22 

23from typing import TYPE_CHECKING, Any 

24 

25from jinja2 import ChoiceLoader, PackageLoader, PrefixLoader 

26from starlette.responses import RedirectResponse 

27 

28from ..detectors import LABELS, SEVERITY 

29from ..models import group_queries 

30from ..storage import Filters 

31 

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} 

39 

40if TYPE_CHECKING: # pragma: no cover 

41 from starlette.requests import Request 

42 

43 from .. import Profiler 

44 

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" 

48 

49 

50def _install_loader(templates: Any) -> None: 

51 """Teach SQLAdmin's Jinja environment where our templates live. 

52 

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]) 

67 

68 

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. 

79 

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. 

92 

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 

97 

98 if not profile_admin: 

99 profiler.exclude(admin.base_url) 

100 

101 _install_loader(admin.templates) 

102 storage = profiler.storage 

103 config = profiler.config 

104 

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" 

112 

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 

118 

119 # Bound late, because the class is not defined yet where `context` is used. 

120 view_name, view_icon, view_category = name, icon, category 

121 

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" 

130 

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 } 

141 

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 ) 

152 

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 ) 

170 

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 ) 

178 

179 @expose("/profiler/clear", methods=["POST"]) 

180 async def clear(self, request: Request) -> Any: 

181 """Discard the recorded history. 

182 

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) 

190 

191 admin.add_base_view(ProfilerView) 

192 return ProfilerView 

193 

194 

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