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

91 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2024-12-28 21:19 -0600

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 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, **kwargs): 

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, **kwargs): 

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

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 include_people: You can pass this flag to indicate the 

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

148 """ 

149 items = [] 

150 

151 if kwargs.get('include_people'): 

152 items.extend([ 

153 { 

154 'title': "All People", 

155 'route': 'people', 

156 'perm': 'people.list', 

157 }, 

158 ]) 

159 

160 items.extend([ 

161 { 

162 'title': "Users", 

163 'route': 'users', 

164 'perm': 'users.list', 

165 }, 

166 { 

167 'title': "Roles", 

168 'route': 'roles', 

169 'perm': 'roles.list', 

170 }, 

171 { 

172 'title': "Permissions", 

173 'route': 'permissions', 

174 'perm': 'permissions.list', 

175 }, 

176 {'type': 'sep'}, 

177 { 

178 'title': "Email Settings", 

179 'route': 'email_settings', 

180 'perm': 'email_settings.list', 

181 }, 

182 {'type': 'sep'}, 

183 { 

184 'title': "App Info", 

185 'route': 'appinfo', 

186 'perm': 'appinfo.list', 

187 }, 

188 { 

189 'title': "Raw Settings", 

190 'route': 'settings', 

191 'perm': 'settings.list', 

192 }, 

193 { 

194 'title': "Upgrades", 

195 'route': 'upgrades', 

196 'perm': 'upgrades.list', 

197 }, 

198 ]) 

199 

200 return { 

201 'title': "Admin", 

202 'type': 'menu', 

203 'items': items, 

204 } 

205 

206 ############################## 

207 # default internal logic 

208 ############################## 

209 

210 def do_make_menus(self, request, **kwargs): 

211 """ 

212 This method is responsible for constructing the final menu 

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

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

215 user permissions. 

216 

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

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

219 """ 

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

221 

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

223 # that somewhat to produce our final menus 

224 self._mark_allowed(request, raw_menus) 

225 final_menus = [] 

226 for topitem in raw_menus: 

227 

228 if topitem['allowed']: 

229 

230 if topitem.get('type') == 'link': 

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

232 

233 else: # assuming 'menu' type 

234 

235 menu_items = [] 

236 for item in topitem['items']: 

237 if not item['allowed']: 

238 continue 

239 

240 # nested submenu 

241 if item.get('type') == 'menu': 

242 submenu_items = [] 

243 for subitem in item['items']: 

244 if subitem['allowed']: 

245 submenu_items.append(self._make_menu_entry(request, subitem)) 

246 menu_items.append({ 

247 'type': 'submenu', 

248 'title': item['title'], 

249 'items': submenu_items, 

250 'is_menu': True, 

251 'is_sep': False, 

252 }) 

253 

254 elif item.get('type') == 'sep': 

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

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

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

258 if menu_items and not menu_items[-1]['is_sep']: 

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

260 

261 else: # standard menu item 

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

263 

264 # remove final separator if present 

265 if menu_items and menu_items[-1]['is_sep']: 

266 menu_items.pop() 

267 

268 # only add if we wound up with something 

269 assert menu_items 

270 if menu_items: 

271 group = { 

272 'type': 'menu', 

273 'key': topitem.get('key'), 

274 'title': topitem['title'], 

275 'items': menu_items, 

276 'is_menu': True, 

277 'is_link': False, 

278 } 

279 

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

281 # from config but rather explicit definition in 

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

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

284 if not group['key']: 

285 group['key'] = self._make_menu_key(topitem['title']) 

286 

287 final_menus.append(group) 

288 

289 return final_menus 

290 

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

292 """ 

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

294 

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

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

297 loading dynamic menus from config instead. 

298 """ 

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

300 

301 def _is_allowed(self, request, item): 

302 """ 

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

304 current user. 

305 """ 

306 perm = item.get('perm') 

307 if perm: 

308 return request.has_perm(perm) 

309 return True 

310 

311 def _mark_allowed(self, request, menus): 

312 """ 

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

314 not) based on current user permissions. 

315 """ 

316 for topitem in menus: 

317 

318 if topitem.get('type', 'menu') == 'link': 

319 topitem['allowed'] = True 

320 

321 elif topitem.get('type', 'menu') == 'menu': 

322 topitem['allowed'] = False 

323 

324 for item in topitem['items']: 

325 

326 if item.get('type') == 'menu': 

327 for subitem in item['items']: 

328 subitem['allowed'] = self._is_allowed(request, subitem) 

329 

330 item['allowed'] = False 

331 for subitem in item['items']: 

332 if subitem['allowed'] and subitem.get('type') != 'sep': 

333 item['allowed'] = True 

334 break 

335 

336 else: 

337 item['allowed'] = self._is_allowed(request, item) 

338 

339 for item in topitem['items']: 

340 if item['allowed'] and item.get('type') != 'sep': 

341 topitem['allowed'] = True 

342 break 

343 

344 def _make_menu_entry(self, request, item): 

345 """ 

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

347 object, for use in constructing final menu. 

348 """ 

349 # separator 

350 if item.get('type') == 'sep': 

351 return { 

352 'type': 'sep', 

353 'is_menu': False, 

354 'is_sep': True, 

355 } 

356 

357 # standard menu item 

358 entry = { 

359 'type': 'item', 

360 'title': item['title'], 

361 'perm': item.get('perm'), 

362 'target': item.get('target'), 

363 'is_link': True, 

364 'is_menu': False, 

365 'is_sep': False, 

366 } 

367 if item.get('route'): 

368 entry['route'] = item['route'] 

369 try: 

370 entry['url'] = request.route_url(entry['route']) 

371 except KeyError: # happens if no such route 

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

373 entry['url'] = entry['route'] 

374 entry['key'] = entry['route'] 

375 else: 

376 if item.get('url'): 

377 entry['url'] = item['url'] 

378 entry['key'] = self._make_menu_key(entry['title']) 

379 return entry 

380 

381 def _make_menu_key(self, value): 

382 """ 

383 Generate a normalized menu key for the given value. 

384 """ 

385 return re.sub(r'\W', '', value.lower())