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

91 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 15:23 -0600

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 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 

32 

33log = logging.getLogger(__name__) 

34 

35 

36class MenuHandler(GenericHandler): 

37 """ 

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

39 

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

41 their own subclass. In particular the subclass will override 

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

43 

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

45 but instead call 

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

47 :term:`app handler`. 

48 

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

50 :term:`config extension`:: 

51 

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

53 

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

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

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

57 

58 menus = [ 

59 { 

60 'title': "First Dropdown", 

61 'type': 'menu', 

62 'items': [ 

63 { 

64 'title': "Foo", 

65 'route': 'foo', 

66 }, 

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

68 { 

69 'title': "Bar", 

70 'route': 'bar', 

71 }, 

72 ], 

73 }, 

74 { 

75 'title': "Second Dropdown", 

76 'type': 'menu', 

77 'items': [ 

78 { 

79 'title': "Wikipedia", 

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

81 'target': '_blank', 

82 }, 

83 ], 

84 }, 

85 ] 

86 """ 

87 

88 ############################## 

89 # default menu definitions 

90 ############################## 

91 

92 def make_menus(self, request): 

93 """ 

94 Generate the full set of menus for the app. 

95 

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

97 is expected for most apps to override it. 

98 

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

100 

101 The default logic returns a list of menus obtained from 

102 calling these methods: 

103 

104 * :meth:`make_people_menu()` 

105 * :meth:`make_admin_menu()` 

106 """ 

107 return [ 

108 self.make_people_menu(request), 

109 self.make_admin_menu(request), 

110 ] 

111 

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

113 """ 

114 Generate a typical People menu. 

115 

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

117 is expected for most apps to override it. 

118 

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

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

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

122 """ 

123 return { 

124 "title": "People", 

125 "type": "menu", 

126 "items": [ 

127 { 

128 "title": "All People", 

129 "route": "people", 

130 "perm": "people.list", 

131 }, 

132 ], 

133 } 

134 

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

136 """ 

137 Generate a typical Admin menu. 

138 

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

140 is expected for most apps to override it. 

141 

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

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

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

145 

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

147 

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

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

150 """ 

151 items = [] 

152 

153 if kwargs.get("include_people"): 

154 items.extend( 

155 [ 

156 { 

157 "title": "All People", 

158 "route": "people", 

159 "perm": "people.list", 

160 }, 

161 ] 

162 ) 

163 

164 items.extend( 

165 [ 

166 { 

167 "title": "Users", 

168 "route": "users", 

169 "perm": "users.list", 

170 }, 

171 { 

172 "title": "Roles", 

173 "route": "roles", 

174 "perm": "roles.list", 

175 }, 

176 { 

177 "title": "Permissions", 

178 "route": "permissions", 

179 "perm": "permissions.list", 

180 }, 

181 {"type": "sep"}, 

182 { 

183 "title": "Email Settings", 

184 "route": "email_settings", 

185 "perm": "email_settings.list", 

186 }, 

187 {"type": "sep"}, 

188 { 

189 "title": "App Info", 

190 "route": "appinfo", 

191 "perm": "appinfo.list", 

192 }, 

193 { 

194 "title": "Raw Settings", 

195 "route": "settings", 

196 "perm": "settings.list", 

197 }, 

198 { 

199 "title": "Upgrades", 

200 "route": "upgrades", 

201 "perm": "upgrades.list", 

202 }, 

203 ] 

204 ) 

205 

206 return { 

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

208 "type": "menu", 

209 "items": items, 

210 } 

211 

212 ############################## 

213 # default internal logic 

214 ############################## 

215 

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

217 """ 

218 This method is responsible for constructing the final menu 

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

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

221 user permissions. 

222 

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

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

225 """ 

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

227 

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

229 # that somewhat to produce our final menus 

230 self._mark_allowed(request, raw_menus) 

231 final_menus = [] 

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

233 

234 if topitem["allowed"]: 

235 

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

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

238 

239 else: # assuming 'menu' type 

240 

241 menu_items = [] 

242 for item in topitem["items"]: 

243 if not item["allowed"]: 

244 continue 

245 

246 # nested submenu 

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

248 submenu_items = [] 

249 for subitem in item["items"]: 

250 if subitem["allowed"]: 

251 submenu_items.append( 

252 self._make_menu_entry(request, subitem) 

253 ) 

254 menu_items.append( 

255 { 

256 "type": "submenu", 

257 "title": item["title"], 

258 "items": submenu_items, 

259 "is_menu": True, 

260 "is_sep": False, 

261 } 

262 ) 

263 

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

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

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

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

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

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

270 

271 else: # standard menu item 

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

273 

274 # remove final separator if present 

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

276 menu_items.pop() 

277 

278 # only add if we wound up with something 

279 assert menu_items 

280 if menu_items: 

281 group = { 

282 "type": "menu", 

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

284 "title": topitem["title"], 

285 "items": menu_items, 

286 "is_menu": True, 

287 "is_link": False, 

288 } 

289 

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

291 # from config but rather explicit definition in 

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

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

294 if not group["key"]: 

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

296 

297 final_menus.append(group) 

298 

299 return final_menus 

300 

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

302 """ 

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

304 

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

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

307 loading dynamic menus from config instead. 

308 """ 

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

310 

311 def _is_allowed(self, request, item): 

312 """ 

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

314 current user. 

315 """ 

316 perm = item.get("perm") 

317 if perm: 

318 return request.has_perm(perm) 

319 return True 

320 

321 def _mark_allowed(self, request, menus): 

322 """ 

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

324 not) based on current user permissions. 

325 """ 

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

327 

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

329 topitem["allowed"] = True 

330 

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

332 topitem["allowed"] = False 

333 

334 for item in topitem["items"]: 

335 

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

337 for subitem in item["items"]: 

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

339 

340 item["allowed"] = False 

341 for subitem in item["items"]: 

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

343 item["allowed"] = True 

344 break 

345 

346 else: 

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

348 

349 for item in topitem["items"]: 

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

351 topitem["allowed"] = True 

352 break 

353 

354 def _make_menu_entry(self, request, item): 

355 """ 

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

357 object, for use in constructing final menu. 

358 """ 

359 # separator 

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

361 return { 

362 "type": "sep", 

363 "is_menu": False, 

364 "is_sep": True, 

365 } 

366 

367 # standard menu item 

368 entry = { 

369 "type": "item", 

370 "title": item["title"], 

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

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

373 "is_link": True, 

374 "is_menu": False, 

375 "is_sep": False, 

376 } 

377 if item.get("route"): 

378 entry["route"] = item["route"] 

379 try: 

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

381 except KeyError: # happens if no such route 

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

383 entry["url"] = entry["route"] 

384 entry["key"] = entry["route"] 

385 else: 

386 if item.get("url"): 

387 entry["url"] = item["url"] 

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

389 return entry 

390 

391 def _make_menu_key(self, value): 

392 """ 

393 Generate a normalized menu key for the given value. 

394 """ 

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