Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/menus.py: 100%

91 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 16:36 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Main Menu 

25""" 

26 

27import re 

28import logging 

29 

30from wuttjamaican.app import GenericHandler 

31 

32log = logging.getLogger(__name__) 

33 

34 

35class MenuHandler(GenericHandler): 

36 """ 

37 Base class and default implementation for :term:`menu handler`. 

38 

39 It is assumed that most apps will override the menu handler with 

40 their own subclass. In particular the subclass will override 

41 :meth:`make_menus()` and/or :meth:`make_admin_menu()`. 

42 

43 The app should normally not instantiate the menu handler directly, 

44 but instead call 

45 :meth:`~wuttaweb.app.WebAppProvider.get_web_menu_handler()` on the 

46 :term:`app handler`. 

47 

48 To configure your menu handler to be used, do this within your 

49 :term:`config extension`:: 

50 

51 config.setdefault('wuttaweb.menus.handler_spec', 'poser.web.menus:PoserMenuHandler') 

52 

53 The core web app will call :meth:`do_make_menus()` to get the 

54 final (possibly filtered) menu set for the current user. The 

55 menu set should be a list of dicts, for example:: 

56 

57 menus = [ 

58 { 

59 'title': "First Dropdown", 

60 'type': 'menu', 

61 'items': [ 

62 { 

63 'title': "Foo", 

64 'route': 'foo', 

65 }, 

66 {'type': 'sep'}, # horizontal line 

67 { 

68 'title': "Bar", 

69 'route': 'bar', 

70 }, 

71 ], 

72 }, 

73 { 

74 'title': "Second Dropdown", 

75 'type': 'menu', 

76 'items': [ 

77 { 

78 'title': "Wikipedia", 

79 'url': 'https://en.wikipedia.org', 

80 'target': '_blank', 

81 }, 

82 ], 

83 }, 

84 ] 

85 """ 

86 

87 ############################## 

88 # default menu definitions 

89 ############################## 

90 

91 def make_menus(self, request): 

92 """ 

93 Generate the full set of menus for the app. 

94 

95 This method provides a semi-sane menu set by default, but it 

96 is expected for most apps to override it. 

97 

98 The return value should be a list of dicts as described above. 

99 

100 The default logic returns a list of menus obtained from 

101 calling these methods: 

102 

103 * :meth:`make_people_menu()` 

104 * :meth:`make_admin_menu()` 

105 """ 

106 return [ 

107 self.make_people_menu(request), 

108 self.make_admin_menu(request), 

109 ] 

110 

111 def make_people_menu(self, request): # pylint: disable=unused-argument 

112 """ 

113 Generate a typical People menu. 

114 

115 This method provides a semi-sane menu set by default, but it 

116 is expected for most apps to override it. 

117 

118 The return value for this method should be a *single* dict, 

119 which will ultimately be one element of the final list of 

120 dicts as described in :class:`MenuHandler`. 

121 """ 

122 return { 

123 "title": "People", 

124 "type": "menu", 

125 "items": [ 

126 { 

127 "title": "All People", 

128 "route": "people", 

129 "perm": "people.list", 

130 }, 

131 ], 

132 } 

133 

134 def make_admin_menu(self, request, **kwargs): # pylint: disable=unused-argument 

135 """ 

136 Generate a typical Admin menu. 

137 

138 This method provides a semi-sane menu set by default, but it 

139 is expected for most apps to override it. 

140 

141 The return value for this method should be a *single* dict, 

142 which will ultimately be one element of the final list of 

143 dicts as described in :class:`MenuHandler`. 

144 

145 :param title: Override the menu title; default is "Admin". 

146 

147 :param include_people: You can pass this flag to indicate the 

148 admin menu should contain an entry for the "People" view. 

149 """ 

150 items = [] 

151 

152 if kwargs.get("include_people"): 

153 items.extend( 

154 [ 

155 { 

156 "title": "All People", 

157 "route": "people", 

158 "perm": "people.list", 

159 }, 

160 ] 

161 ) 

162 

163 items.extend( 

164 [ 

165 { 

166 "title": "Users", 

167 "route": "users", 

168 "perm": "users.list", 

169 }, 

170 { 

171 "title": "Roles", 

172 "route": "roles", 

173 "perm": "roles.list", 

174 }, 

175 { 

176 "title": "Permissions", 

177 "route": "permissions", 

178 "perm": "permissions.list", 

179 }, 

180 {"type": "sep"}, 

181 { 

182 "title": "Problem Checks", 

183 "route": "problem_checks", 

184 "perm": "problem_checks.list", 

185 }, 

186 { 

187 "title": "Email Settings", 

188 "route": "email_settings", 

189 "perm": "email_settings.list", 

190 }, 

191 { 

192 "title": "Raw Settings", 

193 "route": "settings", 

194 "perm": "settings.list", 

195 }, 

196 {"type": "sep"}, 

197 { 

198 "title": "App Info", 

199 "route": "appinfo", 

200 "perm": "appinfo.list", 

201 }, 

202 { 

203 "title": "Upgrades", 

204 "route": "upgrades", 

205 "perm": "upgrades.list", 

206 }, 

207 ] 

208 ) 

209 

210 return { 

211 "title": kwargs.get("title", "Admin"), 

212 "type": "menu", 

213 "items": items, 

214 } 

215 

216 ############################## 

217 # default internal logic 

218 ############################## 

219 

220 def do_make_menus(self, request, **kwargs): # pylint: disable=too-many-branches 

221 """ 

222 This method is responsible for constructing the final menu 

223 set. It first calls :meth:`make_menus()` to get the basic 

224 set, and then it prunes entries as needed based on current 

225 user permissions. 

226 

227 The web app calls this method but you normally should not need 

228 to override it; you can override :meth:`make_menus()` instead. 

229 """ 

230 raw_menus = self._make_raw_menus(request, **kwargs) 

231 

232 # now we have "simple" (raw) menus definition, but must refine 

233 # that somewhat to produce our final menus 

234 self._mark_allowed(request, raw_menus) 

235 final_menus = [] 

236 for topitem in raw_menus: # pylint: disable=too-many-nested-blocks 

237 

238 if topitem["allowed"]: 

239 

240 if topitem.get("type") == "link": 

241 final_menus.append(self._make_menu_entry(request, topitem)) 

242 

243 else: # assuming 'menu' type 

244 

245 menu_items = [] 

246 for item in topitem["items"]: 

247 if not item["allowed"]: 

248 continue 

249 

250 # nested submenu 

251 if item.get("type") == "menu": 

252 submenu_items = [] 

253 for subitem in item["items"]: 

254 if subitem["allowed"]: 

255 submenu_items.append( 

256 self._make_menu_entry(request, subitem) 

257 ) 

258 menu_items.append( 

259 { 

260 "type": "submenu", 

261 "title": item["title"], 

262 "items": submenu_items, 

263 "is_menu": True, 

264 "is_sep": False, 

265 } 

266 ) 

267 

268 elif item.get("type") == "sep": 

269 # we only want to add a sep, *if* we already have some 

270 # menu items (i.e. there is something to separate) 

271 # *and* the last menu item is not a sep (avoid doubles) 

272 if menu_items and not menu_items[-1]["is_sep"]: 

273 menu_items.append(self._make_menu_entry(request, item)) 

274 

275 else: # standard menu item 

276 menu_items.append(self._make_menu_entry(request, item)) 

277 

278 # remove final separator if present 

279 if menu_items and menu_items[-1]["is_sep"]: 

280 menu_items.pop() 

281 

282 # only add if we wound up with something 

283 assert menu_items 

284 if menu_items: 

285 group = { 

286 "type": "menu", 

287 "key": topitem.get("key"), 

288 "title": topitem["title"], 

289 "items": menu_items, 

290 "is_menu": True, 

291 "is_link": False, 

292 } 

293 

294 # topitem w/ no key likely means it did not come 

295 # from config but rather explicit definition in 

296 # code. so we are free to "invent" a (safe) key 

297 # for it, since that is only for editing config 

298 if not group["key"]: 

299 group["key"] = self._make_menu_key(topitem["title"]) 

300 

301 final_menus.append(group) 

302 

303 return final_menus 

304 

305 def _make_raw_menus(self, request, **kwargs): 

306 """ 

307 Construct the initial full set of "raw" menus. 

308 

309 For now this just calls :meth:`make_menus()` which generally 

310 means a "hard-coded" menu set. Eventually it may allow for 

311 loading dynamic menus from config instead. 

312 """ 

313 return self.make_menus(request, **kwargs) 

314 

315 def _is_allowed(self, request, item): 

316 """ 

317 Logic to determine if a given menu item is "allowed" for 

318 current user. 

319 """ 

320 perm = item.get("perm") 

321 if perm: 

322 return request.has_perm(perm) 

323 return True 

324 

325 def _mark_allowed(self, request, menus): 

326 """ 

327 Traverse the menu set, and mark each item as "allowed" (or 

328 not) based on current user permissions. 

329 """ 

330 for topitem in menus: # pylint: disable=too-many-nested-blocks 

331 

332 if topitem.get("type", "menu") == "link": 

333 topitem["allowed"] = True 

334 

335 elif topitem.get("type", "menu") == "menu": 

336 topitem["allowed"] = False 

337 

338 for item in topitem["items"]: 

339 

340 if item.get("type") == "menu": 

341 for subitem in item["items"]: 

342 subitem["allowed"] = self._is_allowed(request, subitem) 

343 

344 item["allowed"] = False 

345 for subitem in item["items"]: 

346 if subitem["allowed"] and subitem.get("type") != "sep": 

347 item["allowed"] = True 

348 break 

349 

350 else: 

351 item["allowed"] = self._is_allowed(request, item) 

352 

353 for item in topitem["items"]: 

354 if item["allowed"] and item.get("type") != "sep": 

355 topitem["allowed"] = True 

356 break 

357 

358 def _make_menu_entry(self, request, item): 

359 """ 

360 Convert a simple menu entry dict, into a proper menu-related 

361 object, for use in constructing final menu. 

362 """ 

363 # separator 

364 if item.get("type") == "sep": 

365 return { 

366 "type": "sep", 

367 "is_menu": False, 

368 "is_sep": True, 

369 } 

370 

371 # standard menu item 

372 entry = { 

373 "type": "item", 

374 "title": item["title"], 

375 "perm": item.get("perm"), 

376 "target": item.get("target"), 

377 "is_link": True, 

378 "is_menu": False, 

379 "is_sep": False, 

380 } 

381 if item.get("route"): 

382 entry["route"] = item["route"] 

383 try: 

384 entry["url"] = request.route_url(entry["route"]) 

385 except KeyError: # happens if no such route 

386 log.warning("invalid route name for menu entry: %s", entry) 

387 entry["url"] = entry["route"] 

388 entry["key"] = entry["route"] 

389 else: 

390 if item.get("url"): 

391 entry["url"] = item["url"] 

392 entry["key"] = self._make_menu_key(entry["title"]) 

393 return entry 

394 

395 def _make_menu_key(self, value): 

396 """ 

397 Generate a normalized menu key for the given value. 

398 """ 

399 return re.sub(r"\W", "", value.lower())