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

87 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-26 14:40 -0500

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 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 return { 

147 'title': "Admin", 

148 'type': 'menu', 

149 'items': [ 

150 { 

151 'title': "Users", 

152 'route': 'users', 

153 'perm': 'users.list', 

154 }, 

155 { 

156 'title': "Roles", 

157 'route': 'roles', 

158 'perm': 'roles.list', 

159 }, 

160 {'type': 'sep'}, 

161 { 

162 'title': "App Info", 

163 'route': 'appinfo', 

164 'perm': 'appinfo.list', 

165 }, 

166 { 

167 'title': "Raw Settings", 

168 'route': 'settings', 

169 'perm': 'settings.list', 

170 }, 

171 { 

172 'title': "Upgrades", 

173 'route': 'upgrades', 

174 'perm': 'upgrades.list', 

175 }, 

176 ], 

177 } 

178 

179 ############################## 

180 # default internal logic 

181 ############################## 

182 

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

184 """ 

185 This method is responsible for constructing the final menu 

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

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

188 user permissions. 

189 

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

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

192 """ 

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

194 

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

196 # that somewhat to produce our final menus 

197 self._mark_allowed(request, raw_menus) 

198 final_menus = [] 

199 for topitem in raw_menus: 

200 

201 if topitem['allowed']: 

202 

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

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

205 

206 else: # assuming 'menu' type 

207 

208 menu_items = [] 

209 for item in topitem['items']: 

210 if not item['allowed']: 

211 continue 

212 

213 # nested submenu 

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

215 submenu_items = [] 

216 for subitem in item['items']: 

217 if subitem['allowed']: 

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

219 menu_items.append({ 

220 'type': 'submenu', 

221 'title': item['title'], 

222 'items': submenu_items, 

223 'is_menu': True, 

224 'is_sep': False, 

225 }) 

226 

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

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

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

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

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

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

233 

234 else: # standard menu item 

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

236 

237 # remove final separator if present 

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

239 menu_items.pop() 

240 

241 # only add if we wound up with something 

242 assert menu_items 

243 if menu_items: 

244 group = { 

245 'type': 'menu', 

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

247 'title': topitem['title'], 

248 'items': menu_items, 

249 'is_menu': True, 

250 'is_link': False, 

251 } 

252 

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

254 # from config but rather explicit definition in 

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

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

257 if not group['key']: 

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

259 

260 final_menus.append(group) 

261 

262 return final_menus 

263 

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

265 """ 

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

267 

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

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

270 loading dynamic menus from config instead. 

271 """ 

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

273 

274 def _is_allowed(self, request, item): 

275 """ 

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

277 current user. 

278 """ 

279 perm = item.get('perm') 

280 if perm: 

281 return request.has_perm(perm) 

282 return True 

283 

284 def _mark_allowed(self, request, menus): 

285 """ 

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

287 not) based on current user permissions. 

288 """ 

289 for topitem in menus: 

290 

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

292 topitem['allowed'] = True 

293 

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

295 topitem['allowed'] = False 

296 

297 for item in topitem['items']: 

298 

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

300 for subitem in item['items']: 

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

302 

303 item['allowed'] = False 

304 for subitem in item['items']: 

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

306 item['allowed'] = True 

307 break 

308 

309 else: 

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

311 

312 for item in topitem['items']: 

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

314 topitem['allowed'] = True 

315 break 

316 

317 def _make_menu_entry(self, request, item): 

318 """ 

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

320 object, for use in constructing final menu. 

321 """ 

322 # separator 

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

324 return { 

325 'type': 'sep', 

326 'is_menu': False, 

327 'is_sep': True, 

328 } 

329 

330 # standard menu item 

331 entry = { 

332 'type': 'item', 

333 'title': item['title'], 

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

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

336 'is_link': True, 

337 'is_menu': False, 

338 'is_sep': False, 

339 } 

340 if item.get('route'): 

341 entry['route'] = item['route'] 

342 try: 

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

344 except KeyError: # happens if no such route 

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

346 entry['url'] = entry['route'] 

347 entry['key'] = entry['route'] 

348 else: 

349 if item.get('url'): 

350 entry['url'] = item['url'] 

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

352 return entry 

353 

354 def _make_menu_key(self, value): 

355 """ 

356 Generate a normalized menu key for the given value. 

357 """ 

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