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
« 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"""
27import re
28import logging
30from wuttjamaican.app import GenericHandler
33log = logging.getLogger(__name__)
36class MenuHandler(GenericHandler):
37 """
38 Base class and default implementation for :term:`menu handler`.
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()`.
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`.
49 To configure your menu handler to be used, do this within your
50 :term:`config extension`::
52 config.setdefault('wuttaweb.menus.handler_spec', 'poser.web.menus:PoserMenuHandler')
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::
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 """
88 ##############################
89 # default menu definitions
90 ##############################
92 def make_menus(self, request):
93 """
94 Generate the full set of menus for the app.
96 This method provides a semi-sane menu set by default, but it
97 is expected for most apps to override it.
99 The return value should be a list of dicts as described above.
101 The default logic returns a list of menus obtained from
102 calling these methods:
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 ]
112 def make_people_menu(self, request): # pylint: disable=unused-argument
113 """
114 Generate a typical People menu.
116 This method provides a semi-sane menu set by default, but it
117 is expected for most apps to override it.
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 }
135 def make_admin_menu(self, request, **kwargs): # pylint: disable=unused-argument
136 """
137 Generate a typical Admin menu.
139 This method provides a semi-sane menu set by default, but it
140 is expected for most apps to override it.
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`.
146 :param title: Override the menu title; default is "Admin".
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 = []
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 )
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 )
206 return {
207 "title": kwargs.get("title", "Admin"),
208 "type": "menu",
209 "items": items,
210 }
212 ##############################
213 # default internal logic
214 ##############################
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.
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)
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
234 if topitem["allowed"]:
236 if topitem.get("type") == "link":
237 final_menus.append(self._make_menu_entry(request, topitem))
239 else: # assuming 'menu' type
241 menu_items = []
242 for item in topitem["items"]:
243 if not item["allowed"]:
244 continue
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 )
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))
271 else: # standard menu item
272 menu_items.append(self._make_menu_entry(request, item))
274 # remove final separator if present
275 if menu_items and menu_items[-1]["is_sep"]:
276 menu_items.pop()
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 }
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"])
297 final_menus.append(group)
299 return final_menus
301 def _make_raw_menus(self, request, **kwargs):
302 """
303 Construct the initial full set of "raw" menus.
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)
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
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
328 if topitem.get("type", "menu") == "link":
329 topitem["allowed"] = True
331 elif topitem.get("type", "menu") == "menu":
332 topitem["allowed"] = False
334 for item in topitem["items"]:
336 if item.get("type") == "menu":
337 for subitem in item["items"]:
338 subitem["allowed"] = self._is_allowed(request, subitem)
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
346 else:
347 item["allowed"] = self._is_allowed(request, item)
349 for item in topitem["items"]:
350 if item["allowed"] and item.get("type") != "sep":
351 topitem["allowed"] = True
352 break
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 }
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
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())