WuttaWeb 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/CHANGELOG.md +8 -0
  2. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/PKG-INFO +7 -9
  3. wuttaweb-0.1.0/README.rst → wuttaweb-0.2.0/README.md +1 -3
  4. wuttaweb-0.2.0/docs/api/wuttaweb/handler.rst +6 -0
  5. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/index.rst +2 -0
  6. wuttaweb-0.2.0/docs/api/wuttaweb/menus.rst +6 -0
  7. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/index.rst +5 -0
  8. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/pyproject.toml +10 -6
  9. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/app.py +27 -0
  10. wuttaweb-0.2.0/src/wuttaweb/handler.py +57 -0
  11. wuttaweb-0.2.0/src/wuttaweb/menus.py +307 -0
  12. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/subscribers.py +13 -2
  13. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/templates/base.mako +5 -0
  14. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/templates/base_meta.mako +1 -1
  15. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tasks.py +1 -1
  16. wuttaweb-0.2.0/tests/test_handler.py +20 -0
  17. wuttaweb-0.2.0/tests/test_menus.py +321 -0
  18. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/.gitignore +0 -0
  19. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/COPYING.txt +0 -0
  20. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/Makefile +0 -0
  21. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/_static/.keepme +0 -0
  22. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/index.rst +0 -0
  23. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/app.rst +0 -0
  24. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/helpers.rst +0 -0
  25. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/static.rst +0 -0
  26. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/subscribers.rst +0 -0
  27. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/util.rst +0 -0
  28. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/views.base.rst +0 -0
  29. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/views.common.rst +0 -0
  30. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/api/wuttaweb/views.rst +0 -0
  31. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/conf.py +0 -0
  32. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/glossary.rst +0 -0
  33. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/make.bat +0 -0
  34. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/docs/narr/index.rst +0 -0
  35. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/__init__.py +0 -0
  36. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/_version.py +0 -0
  37. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/helpers.py +0 -0
  38. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/static/__init__.py +0 -0
  39. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/static/img/testing.png +0 -0
  40. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/templates/home.mako +0 -0
  41. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/templates/page.mako +0 -0
  42. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/util.py +0 -0
  43. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/views/__init__.py +0 -0
  44. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/views/base.py +0 -0
  45. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/src/wuttaweb/views/common.py +0 -0
  46. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/__init__.py +0 -0
  47. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/test_app.py +0 -0
  48. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/test_helpers.py +0 -0
  49. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/test_static.py +0 -0
  50. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/test_subscribers.py +0 -0
  51. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/test_util.py +0 -0
  52. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/views/__init__.py +0 -0
  53. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/views/test___init__.py +0 -0
  54. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/views/test_base.py +0 -0
  55. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tests/views/test_common.py +0 -0
  56. {wuttaweb-0.1.0 → wuttaweb-0.2.0}/tox.ini +0 -0
@@ -5,6 +5,14 @@ All notable changes to wuttaweb will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v0.2.0 (2024-07-14)
9
+
10
+ ### Feat
11
+
12
+ - add basic support for menu handler
13
+
14
+ - add "web handler" feature; it must get the menu handler
15
+
8
16
  ## v0.1.0 (2024-07-12)
9
17
 
10
18
  ### Feat
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: WuttaWeb
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Web App for Wutta Framework
5
- Project-URL: Homepage, https://rattailproject.org/
6
- Project-URL: Repository, https://kallithea.rattailproject.org/rattail-project/wuttaweb
7
- Project-URL: Changelog, https://kallithea.rattailproject.org/rattail-project/wuttaweb/files/master/CHANGELOG.md
5
+ Project-URL: Homepage, https://wuttaproject.org/
6
+ Project-URL: Repository, https://forgejo.wuttaproject.org/wutta/wuttaweb
7
+ Project-URL: Changelog, https://forgejo.wuttaproject.org/wutta/wuttaweb/src/branch/master/CHANGELOG.md
8
8
  Author-email: Lance Edgar <lance@edbob.org>
9
9
  License: GNU GPL v3+
10
10
  License-File: COPYING.txt
@@ -29,19 +29,17 @@ Requires-Dist: pyramid-mako
29
29
  Requires-Dist: pyramid>=2
30
30
  Requires-Dist: waitress
31
31
  Requires-Dist: webhelpers2
32
- Requires-Dist: wuttjamaican[db]>=0.6.1
32
+ Requires-Dist: wuttjamaican[db]>=0.7.0
33
33
  Provides-Extra: docs
34
34
  Requires-Dist: furo; extra == 'docs'
35
35
  Requires-Dist: sphinx; extra == 'docs'
36
36
  Provides-Extra: tests
37
37
  Requires-Dist: pytest-cov; extra == 'tests'
38
38
  Requires-Dist: tox; extra == 'tests'
39
- Description-Content-Type: text/x-rst
39
+ Description-Content-Type: text/markdown
40
40
 
41
41
 
42
- ==========
43
- wuttaweb
44
- ==========
42
+ # wuttaweb
45
43
 
46
44
  Web app for Wutta Framework
47
45
 
@@ -1,7 +1,5 @@
1
1
 
2
- ==========
3
- wuttaweb
4
- ==========
2
+ # wuttaweb
5
3
 
6
4
  Web app for Wutta Framework
7
5
 
@@ -0,0 +1,6 @@
1
+
2
+ ``wuttaweb.handler``
3
+ ====================
4
+
5
+ .. automodule:: wuttaweb.handler
6
+ :members:
@@ -8,7 +8,9 @@
8
8
  :maxdepth: 1
9
9
 
10
10
  app
11
+ handler
11
12
  helpers
13
+ menus
12
14
  static
13
15
  subscribers
14
16
  util
@@ -0,0 +1,6 @@
1
+
2
+ ``wuttaweb.menus``
3
+ ==================
4
+
5
+ .. automodule:: wuttaweb.menus
6
+ :members:
@@ -6,6 +6,11 @@ This package provides a "web layer" for custom apps.
6
6
 
7
7
  It uses traditional server-side rendering with VueJS on the front-end.
8
8
 
9
+ Good documentation and 100% `test coverage`_ are priorities for this
10
+ project.
11
+
12
+ .. _test coverage: https://buildbot.rattailproject.org/coverage/wuttaweb/
13
+
9
14
  .. toctree::
10
15
  :maxdepth: 3
11
16
  :caption: Contents:
@@ -6,9 +6,9 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "WuttaWeb"
9
- version = "0.1.0"
9
+ version = "0.2.0"
10
10
  description = "Web App for Wutta Framework"
11
- readme = "README.rst"
11
+ readme = "README.md"
12
12
  authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
13
13
  license = {text = "GNU GPL v3+"}
14
14
  classifiers = [
@@ -35,7 +35,7 @@ dependencies = [
35
35
  "pyramid_mako",
36
36
  "waitress",
37
37
  "WebHelpers2",
38
- "WuttJamaican[db]>=0.6.1",
38
+ "WuttJamaican[db]>=0.7.0",
39
39
  ]
40
40
 
41
41
 
@@ -48,10 +48,14 @@ tests = ["pytest-cov", "tox"]
48
48
  main = "wuttaweb.app:main"
49
49
 
50
50
 
51
+ [project.entry-points."wutta.app.providers"]
52
+ wuttaweb = "wuttaweb.app:WebAppProvider"
53
+
54
+
51
55
  [project.urls]
52
- Homepage = "https://rattailproject.org/"
53
- Repository = "https://kallithea.rattailproject.org/rattail-project/wuttaweb"
54
- Changelog = "https://kallithea.rattailproject.org/rattail-project/wuttaweb/files/master/CHANGELOG.md"
56
+ Homepage = "https://wuttaproject.org/"
57
+ Repository = "https://forgejo.wuttaproject.org/wutta/wuttaweb"
58
+ Changelog = "https://forgejo.wuttaproject.org/wutta/wuttaweb/src/branch/master/CHANGELOG.md"
55
59
 
56
60
 
57
61
  [tool.commitizen]
@@ -26,11 +26,38 @@ Application
26
26
 
27
27
  import os
28
28
 
29
+ from wuttjamaican.app import AppProvider
29
30
  from wuttjamaican.conf import make_config
30
31
 
31
32
  from pyramid.config import Configurator
32
33
 
33
34
 
35
+ class WebAppProvider(AppProvider):
36
+ """
37
+ The :term:`app provider` for WuttaWeb. This adds some methods
38
+ specific to web apps.
39
+ """
40
+
41
+ def get_web_handler(self, **kwargs):
42
+ """
43
+ Get the configured "web" handler for the app.
44
+
45
+ Specify a custom handler in your config file like this:
46
+
47
+ .. code-block:: ini
48
+
49
+ [wutta]
50
+ web.handler_spec = poser.web.handler:PoserWebHandler
51
+
52
+ :returns: Instance of :class:`~wuttaweb.handler.WebHandler`.
53
+ """
54
+ if 'web_handler' not in self.__dict__:
55
+ spec = self.config.get(f'{self.appname}.web.handler_spec',
56
+ default='wuttaweb.handler:WebHandler')
57
+ self.web_handler = self.app.load_object(spec)(self.config)
58
+ return self.web_handler
59
+
60
+
34
61
  def make_wutta_config(settings):
35
62
  """
36
63
  Make a WuttaConfig object from the given settings.
@@ -0,0 +1,57 @@
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
+ """
24
+ Web Handler
25
+
26
+ This defines the :term:`handler` for the web layer.
27
+ """
28
+
29
+ from wuttjamaican.app import GenericHandler
30
+
31
+
32
+ class WebHandler(GenericHandler):
33
+ """
34
+ Base class and default implementation for the "web" :term:`handler`.
35
+
36
+ This is responsible for determining the "menu handler" and
37
+ (eventually) possibly other things.
38
+ """
39
+
40
+ def get_menu_handler(self, **kwargs):
41
+ """
42
+ Get the configured "menu" handler for the web app.
43
+
44
+ Specify a custom handler in your config file like this:
45
+
46
+ .. code-block:: ini
47
+
48
+ [wutta.web]
49
+ menus.handler_spec = poser.web.menus:PoserMenuHandler
50
+
51
+ :returns: Instance of :class:`~wuttaweb.menus.MenuHandler`.
52
+ """
53
+ if not hasattr(self, 'menu_handler'):
54
+ spec = self.config.get(f'{self.appname}.web.menus.handler_spec',
55
+ default='wuttaweb.menus:MenuHandler')
56
+ self.menu_handler = self.app.load_object(spec)(self.config)
57
+ return self.menu_handler
@@ -0,0 +1,307 @@
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
+ """
24
+ Main Menu
25
+ """
26
+
27
+ import re
28
+ import logging
29
+
30
+ from wuttjamaican.app import GenericHandler
31
+
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ class 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
+ return [
102
+ self.make_admin_menu(request),
103
+ ]
104
+
105
+ def make_admin_menu(self, request, **kwargs):
106
+ """
107
+ Generate a typical Admin menu.
108
+
109
+ This method provides a semi-sane menu set by default, but it
110
+ is expected for most apps to override it.
111
+
112
+ The return value for this method should be a *single* dict,
113
+ which will ultimately be one element of the final list of
114
+ dicts as described above.
115
+ """
116
+ return {
117
+ 'title': "Admin",
118
+ 'type': 'menu',
119
+ 'items': [
120
+ {
121
+ 'title': "TODO!",
122
+ 'url': '#',
123
+ },
124
+ ],
125
+ }
126
+
127
+ ##############################
128
+ # default internal logic
129
+ ##############################
130
+
131
+ def do_make_menus(self, request, **kwargs):
132
+ """
133
+ This method is responsible for constructing the final menu
134
+ set. It first calls :meth:`make_menus()` to get the basic
135
+ set, and then it prunes entries as needed based on current
136
+ user permissions.
137
+
138
+ The web app calls this method but you normally should not need
139
+ to override it; you can override :meth:`make_menus()` instead.
140
+ """
141
+ raw_menus = self._make_raw_menus(request, **kwargs)
142
+
143
+ # now we have "simple" (raw) menus definition, but must refine
144
+ # that somewhat to produce our final menus
145
+ self._mark_allowed(request, raw_menus)
146
+ final_menus = []
147
+ for topitem in raw_menus:
148
+
149
+ if topitem['allowed']:
150
+
151
+ if topitem.get('type') == 'link':
152
+ final_menus.append(self._make_menu_entry(request, topitem))
153
+
154
+ else: # assuming 'menu' type
155
+
156
+ menu_items = []
157
+ for item in topitem['items']:
158
+ if not item['allowed']:
159
+ continue
160
+
161
+ # nested submenu
162
+ if item.get('type') == 'menu':
163
+ submenu_items = []
164
+ for subitem in item['items']:
165
+ if subitem['allowed']:
166
+ submenu_items.append(self._make_menu_entry(request, subitem))
167
+ menu_items.append({
168
+ 'type': 'submenu',
169
+ 'title': item['title'],
170
+ 'items': submenu_items,
171
+ 'is_menu': True,
172
+ 'is_sep': False,
173
+ })
174
+
175
+ elif item.get('type') == 'sep':
176
+ # we only want to add a sep, *if* we already have some
177
+ # menu items (i.e. there is something to separate)
178
+ # *and* the last menu item is not a sep (avoid doubles)
179
+ if menu_items and not menu_items[-1]['is_sep']:
180
+ menu_items.append(self._make_menu_entry(request, item))
181
+
182
+ else: # standard menu item
183
+ menu_items.append(self._make_menu_entry(request, item))
184
+
185
+ # remove final separator if present
186
+ if menu_items and menu_items[-1]['is_sep']:
187
+ menu_items.pop()
188
+
189
+ # only add if we wound up with something
190
+ assert menu_items
191
+ if menu_items:
192
+ group = {
193
+ 'type': 'menu',
194
+ 'key': topitem.get('key'),
195
+ 'title': topitem['title'],
196
+ 'items': menu_items,
197
+ 'is_menu': True,
198
+ 'is_link': False,
199
+ }
200
+
201
+ # topitem w/ no key likely means it did not come
202
+ # from config but rather explicit definition in
203
+ # code. so we are free to "invent" a (safe) key
204
+ # for it, since that is only for editing config
205
+ if not group['key']:
206
+ group['key'] = self._make_menu_key(topitem['title'])
207
+
208
+ final_menus.append(group)
209
+
210
+ return final_menus
211
+
212
+ def _make_raw_menus(self, request, **kwargs):
213
+ """
214
+ Construct the initial full set of "raw" menus.
215
+
216
+ For now this just calls :meth:`make_menus()` which generally
217
+ means a "hard-coded" menu set. Eventually it may allow for
218
+ loading dynamic menus from config instead.
219
+ """
220
+ return self.make_menus(request, **kwargs)
221
+
222
+ def _is_allowed(self, request, item):
223
+ """
224
+ Logic to determine if a given menu item is "allowed" for
225
+ current user.
226
+ """
227
+ perm = item.get('perm')
228
+ # TODO
229
+ # if perm:
230
+ # return request.has_perm(perm)
231
+ return True
232
+
233
+ def _mark_allowed(self, request, menus):
234
+ """
235
+ Traverse the menu set, and mark each item as "allowed" (or
236
+ not) based on current user permissions.
237
+ """
238
+ for topitem in menus:
239
+
240
+ if topitem.get('type', 'menu') == 'link':
241
+ topitem['allowed'] = True
242
+
243
+ elif topitem.get('type', 'menu') == 'menu':
244
+ topitem['allowed'] = False
245
+
246
+ for item in topitem['items']:
247
+
248
+ if item.get('type') == 'menu':
249
+ for subitem in item['items']:
250
+ subitem['allowed'] = self._is_allowed(request, subitem)
251
+
252
+ item['allowed'] = False
253
+ for subitem in item['items']:
254
+ if subitem['allowed'] and subitem.get('type') != 'sep':
255
+ item['allowed'] = True
256
+ break
257
+
258
+ else:
259
+ item['allowed'] = self._is_allowed(request, item)
260
+
261
+ for item in topitem['items']:
262
+ if item['allowed'] and item.get('type') != 'sep':
263
+ topitem['allowed'] = True
264
+ break
265
+
266
+ def _make_menu_entry(self, request, item):
267
+ """
268
+ Convert a simple menu entry dict, into a proper menu-related
269
+ object, for use in constructing final menu.
270
+ """
271
+ # separator
272
+ if item.get('type') == 'sep':
273
+ return {
274
+ 'type': 'sep',
275
+ 'is_menu': False,
276
+ 'is_sep': True,
277
+ }
278
+
279
+ # standard menu item
280
+ entry = {
281
+ 'type': 'item',
282
+ 'title': item['title'],
283
+ 'perm': item.get('perm'),
284
+ 'target': item.get('target'),
285
+ 'is_link': True,
286
+ 'is_menu': False,
287
+ 'is_sep': False,
288
+ }
289
+ if item.get('route'):
290
+ entry['route'] = item['route']
291
+ try:
292
+ entry['url'] = request.route_url(entry['route'])
293
+ except KeyError: # happens if no such route
294
+ log.warning("invalid route name for menu entry: %s", entry)
295
+ entry['url'] = entry['route']
296
+ entry['key'] = entry['route']
297
+ else:
298
+ if item.get('url'):
299
+ entry['url'] = item['url']
300
+ entry['key'] = self._make_menu_key(entry['title'])
301
+ return entry
302
+
303
+ def _make_menu_key(self, value):
304
+ """
305
+ Generate a normalized menu key for the given value.
306
+ """
307
+ return re.sub(r'\W', '', value.lower())
@@ -36,12 +36,16 @@ hooks contained here, depending on the circumstance.
36
36
  """
37
37
 
38
38
  import json
39
+ import logging
39
40
 
40
41
  from pyramid import threadlocal
41
42
 
42
43
  from wuttaweb import helpers
43
44
 
44
45
 
46
+ log = logging.getLogger(__name__)
47
+
48
+
45
49
  def new_request(event):
46
50
  """
47
51
  Event hook called when processing a new request.
@@ -115,6 +119,12 @@ def before_render(event):
115
119
 
116
120
  Reference to the built-in module, :mod:`python:json`.
117
121
 
122
+ .. data:: 'menus'
123
+
124
+ Set of entries to be shown in the main menu. This is obtained
125
+ by calling :meth:`~wuttaweb.menus.MenuHandler.do_make_menus()`
126
+ on the configured :class:`~wuttaweb.menus.MenuHandler`.
127
+
118
128
  .. data:: 'url'
119
129
 
120
130
  Reference to the request method,
@@ -123,6 +133,7 @@ def before_render(event):
123
133
  request = event.get('request') or threadlocal.get_current_request()
124
134
  config = request.wutta_config
125
135
  app = config.get_app()
136
+ web = app.get_web_handler()
126
137
 
127
138
  context = event
128
139
  context['app'] = app
@@ -131,8 +142,8 @@ def before_render(event):
131
142
  context['url'] = request.route_url
132
143
  context['json'] = json
133
144
 
134
- # TODO
135
- context['menus'] = []
145
+ menus = web.get_menu_handler()
146
+ context['menus'] = menus.do_make_menus(request)
136
147
 
137
148
 
138
149
  def includeme(config):
@@ -103,6 +103,11 @@
103
103
  }
104
104
  % endif
105
105
 
106
+ /* nb. this refers to the "menu-sized" app title in far left of main menu */
107
+ #global-header-title {
108
+ font-weight: bold;
109
+ }
110
+
106
111
  #current-context {
107
112
  padding-left: 0.5rem;
108
113
  }
@@ -16,6 +16,6 @@
16
16
 
17
17
  <%def name="footer()">
18
18
  <p class="has-text-centered">
19
- powered by ${h.link_to("Wutta Framework", 'https://pypi.org/project/WuttJamaican/', target='_blank')}
19
+ powered by ${h.link_to("WuttaWeb", 'https://wuttaproject.org/', target='_blank')}
20
20
  </p>
21
21
  </%def>
@@ -15,7 +15,7 @@ def release(c, skip_tests=False):
15
15
  Release a new version of WuttJamaican
16
16
  """
17
17
  if not skip_tests:
18
- c.run('tox')
18
+ c.run('pytest')
19
19
 
20
20
  # rebuild pkg
21
21
  if os.path.exists('dist'):
@@ -0,0 +1,20 @@
1
+ # -*- coding: utf-8; -*-
2
+
3
+ from unittest import TestCase
4
+
5
+ from wuttjamaican.conf import WuttaConfig
6
+
7
+ from wuttaweb import handler as mod
8
+ from wuttaweb.menus import MenuHandler
9
+
10
+
11
+ class TestWebHandler(TestCase):
12
+
13
+ def setUp(self):
14
+ self.config = WuttaConfig()
15
+ self.app = self.config.get_app()
16
+ self.handler = mod.WebHandler(self.config)
17
+
18
+ def test_menu_handler_default(self):
19
+ menus = self.handler.get_menu_handler()
20
+ self.assertIsInstance(menus, MenuHandler)
@@ -0,0 +1,321 @@
1
+ # -*- coding: utf-8; -*-
2
+
3
+ from unittest import TestCase
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ from wuttjamaican.conf import WuttaConfig
7
+
8
+ from pyramid import testing
9
+
10
+ from wuttaweb import menus as mod
11
+
12
+
13
+ class TestMenuHandler(TestCase):
14
+
15
+ def setUp(self):
16
+ self.config = WuttaConfig()
17
+ self.app = self.config.get_app()
18
+ self.handler = mod.MenuHandler(self.config)
19
+ self.request = testing.DummyRequest()
20
+
21
+ def test_make_admin_menu(self):
22
+ menus = self.handler.make_admin_menu(self.request)
23
+ self.assertIsInstance(menus, dict)
24
+
25
+ def test_make_menus(self):
26
+ menus = self.handler.make_menus(self.request)
27
+ self.assertIsInstance(menus, list)
28
+
29
+ def test_is_allowed(self):
30
+ # TODO: this should test auth/perm handling
31
+ item = {}
32
+ self.assertTrue(self.handler._is_allowed(self.request, item))
33
+
34
+ def test_mark_allowed(self):
35
+
36
+ def make_menus():
37
+ return [
38
+ {
39
+ 'type': 'menu',
40
+ 'items': [
41
+ {'title': "Foo", 'url': '#'},
42
+ {'title': "Bar", 'url': '#'},
43
+ ],
44
+ },
45
+ ]
46
+
47
+ mock_is_allowed = MagicMock()
48
+ with patch.object(self.handler, '_is_allowed', new=mock_is_allowed):
49
+
50
+ # all should be allowed
51
+ mock_is_allowed.return_value = True
52
+ menus = make_menus()
53
+ self.handler._mark_allowed(self.request, menus)
54
+ menu = menus[0]
55
+ self.assertTrue(menu['allowed'])
56
+ foo, bar = menu['items']
57
+ self.assertTrue(foo['allowed'])
58
+ self.assertTrue(bar['allowed'])
59
+
60
+ # none should be allowed
61
+ mock_is_allowed.return_value = False
62
+ menus = make_menus()
63
+ self.handler._mark_allowed(self.request, menus)
64
+ menu = menus[0]
65
+ self.assertFalse(menu['allowed'])
66
+ foo, bar = menu['items']
67
+ self.assertFalse(foo['allowed'])
68
+ self.assertFalse(bar['allowed'])
69
+
70
+ def test_mark_allowed_submenu(self):
71
+
72
+ def make_menus():
73
+ return [
74
+ {
75
+ 'type': 'menu',
76
+ 'items': [
77
+ {'title': "Foo", 'url': '#'},
78
+ {
79
+ 'type': 'menu',
80
+ 'items': [
81
+ {'title': "Bar", 'url': '#'},
82
+ ],
83
+ },
84
+ ],
85
+ },
86
+ ]
87
+
88
+ mock_is_allowed = MagicMock()
89
+ with patch.object(self.handler, '_is_allowed', new=mock_is_allowed):
90
+
91
+ # all should be allowed
92
+ mock_is_allowed.return_value = True
93
+ menus = make_menus()
94
+ self.handler._mark_allowed(self.request, menus)
95
+ menu = menus[0]
96
+ self.assertTrue(menu['allowed'])
97
+ foo, submenu = menu['items']
98
+ self.assertTrue(foo['allowed'])
99
+ self.assertTrue(submenu['allowed'])
100
+ subitem = submenu['items'][0]
101
+ self.assertTrue(subitem['allowed'])
102
+
103
+ # none should be allowed
104
+ mock_is_allowed.return_value = False
105
+ menus = make_menus()
106
+ self.handler._mark_allowed(self.request, menus)
107
+ menu = menus[0]
108
+ self.assertFalse(menu['allowed'])
109
+ foo, submenu = menu['items']
110
+ self.assertFalse(foo['allowed'])
111
+ self.assertFalse(submenu['allowed'])
112
+ subitem = submenu['items'][0]
113
+ self.assertFalse(subitem['allowed'])
114
+
115
+ def test_make_menu_key(self):
116
+ self.assertEqual(self.handler._make_menu_key('foo'), 'foo')
117
+ self.assertEqual(self.handler._make_menu_key('FooBar'), 'foobar')
118
+ self.assertEqual(self.handler._make_menu_key('Foo - $#Bar'), 'foobar')
119
+ self.assertEqual(self.handler._make_menu_key('Foo__Bar'), 'foo__bar')
120
+
121
+ def test_make_menu_entry_item(self):
122
+ item = {'title': "Foo", 'url': '#'}
123
+ entry = self.handler._make_menu_entry(self.request, item)
124
+ self.assertEqual(entry['type'], 'item')
125
+ self.assertEqual(entry['title'], "Foo")
126
+ self.assertEqual(entry['url'], '#')
127
+ self.assertTrue(entry['is_link'])
128
+
129
+ def test_make_menu_entry_item_with_no_url(self):
130
+ item = {'title': "Foo"}
131
+ entry = self.handler._make_menu_entry(self.request, item)
132
+ self.assertEqual(entry['type'], 'item')
133
+ self.assertEqual(entry['title'], "Foo")
134
+ self.assertNotIn('url', entry)
135
+ # nb. still sets is_link = True; basically it's <a> with no href
136
+ self.assertTrue(entry['is_link'])
137
+
138
+ def test_make_menu_entry_item_with_known_route(self):
139
+ item = {'title': "Foo", 'route': 'home'}
140
+ with patch.object(self.request, 'route_url', return_value='/something'):
141
+ entry = self.handler._make_menu_entry(self.request, item)
142
+ self.assertEqual(entry['type'], 'item')
143
+ self.assertEqual(entry['url'], '/something')
144
+ self.assertTrue(entry['is_link'])
145
+
146
+ def test_make_menu_entry_item_with_unknown_route(self):
147
+ item = {'title': "Foo", 'route': 'home'}
148
+ with patch.object(self.request, 'route_url', side_effect=KeyError):
149
+ entry = self.handler._make_menu_entry(self.request, item)
150
+ self.assertEqual(entry['type'], 'item')
151
+ # nb. fake url is used, based on (bad) route name
152
+ self.assertEqual(entry['url'], 'home')
153
+ self.assertTrue(entry['is_link'])
154
+
155
+ def test_make_menu_entry_sep(self):
156
+ item = {'type': 'sep'}
157
+ entry = self.handler._make_menu_entry(self.request, item)
158
+ self.assertEqual(entry['type'], 'sep')
159
+ self.assertTrue(entry['is_sep'])
160
+ self.assertFalse(entry['is_menu'])
161
+
162
+ def test_make_raw_menus(self):
163
+ # minimal test to ensure it calls the other method
164
+ with patch.object(self.handler, 'make_menus') as make_menus:
165
+ self.handler._make_raw_menus(self.request, foo='bar')
166
+ make_menus.assert_called_once_with(self.request, foo='bar')
167
+
168
+ def test_do_make_menus_prune_unallowed_item(self):
169
+ test_menus = [
170
+ {
171
+ 'title': "First Menu",
172
+ 'type': 'menu',
173
+ 'items': [
174
+ {'title': "Foo", 'url': '#'},
175
+ {'title': "Bar", 'url': '#'},
176
+ ],
177
+ },
178
+ ]
179
+
180
+ def is_allowed(request, item):
181
+ if item.get('title') == 'Bar':
182
+ return False
183
+ return True
184
+
185
+ with patch.object(self.handler, 'make_menus', return_value=test_menus):
186
+ with patch.object(self.handler, '_is_allowed', side_effect=is_allowed):
187
+ menus = self.handler.do_make_menus(self.request)
188
+
189
+ # Foo remains but Bar is pruned
190
+ menu = menus[0]
191
+ self.assertEqual(len(menu['items']), 1)
192
+ item = menu['items'][0]
193
+ self.assertEqual(item['title'], 'Foo')
194
+
195
+ def test_do_make_menus_prune_unallowed_menu(self):
196
+ test_menus = [
197
+ {
198
+ 'title': "First Menu",
199
+ 'type': 'menu',
200
+ 'items': [
201
+ {'title': "Foo", 'url': '#'},
202
+ {'title': "Bar", 'url': '#'},
203
+ ],
204
+ },
205
+ {
206
+ 'title': "Second Menu",
207
+ 'type': 'menu',
208
+ 'items': [
209
+ {'title': "Baz", 'url': '#'},
210
+ ],
211
+ },
212
+ ]
213
+
214
+ def is_allowed(request, item):
215
+ if item.get('title') == 'Baz':
216
+ return True
217
+ return False
218
+
219
+ with patch.object(self.handler, 'make_menus', return_value=test_menus):
220
+ with patch.object(self.handler, '_is_allowed', side_effect=is_allowed):
221
+ menus = self.handler.do_make_menus(self.request)
222
+
223
+ # Second/Baz remains but First/Foo/Bar are pruned
224
+ self.assertEqual(len(menus), 1)
225
+ menu = menus[0]
226
+ self.assertEqual(menu['title'], 'Second Menu')
227
+ self.assertEqual(len(menu['items']), 1)
228
+ item = menu['items'][0]
229
+ self.assertEqual(item['title'], 'Baz')
230
+
231
+ def test_do_make_menus_with_top_link(self):
232
+ test_menus = [
233
+ {
234
+ 'title': "First Menu",
235
+ 'type': 'menu',
236
+ 'items': [
237
+ {'title': "Foo", 'url': '#'},
238
+ {'title': "Bar", 'url': '#'},
239
+ ],
240
+ },
241
+ {
242
+ 'title': "Second Link",
243
+ 'type': 'link',
244
+ },
245
+ ]
246
+
247
+ with patch.object(self.handler, 'make_menus', return_value=test_menus):
248
+ with patch.object(self.handler, '_is_allowed', return_value=True):
249
+ menus = self.handler.do_make_menus(self.request)
250
+
251
+ # ensure top link remains
252
+ self.assertEqual(len(menus), 2)
253
+ menu = menus[1]
254
+ self.assertEqual(menu['title'], "Second Link")
255
+
256
+ def test_do_make_menus_with_trailing_sep(self):
257
+ test_menus = [
258
+ {
259
+ 'title': "First Menu",
260
+ 'type': 'menu',
261
+ 'items': [
262
+ {'title': "Foo", 'url': '#'},
263
+ {'title': "Bar", 'url': '#'},
264
+ {'type': 'sep'},
265
+ ],
266
+ },
267
+ ]
268
+
269
+ with patch.object(self.handler, 'make_menus', return_value=test_menus):
270
+ with patch.object(self.handler, '_is_allowed', return_value=True):
271
+ menus = self.handler.do_make_menus(self.request)
272
+
273
+ # ensure trailing sep was pruned
274
+ menu = menus[0]
275
+ self.assertEqual(len(menu['items']), 2)
276
+ foo, bar = menu['items']
277
+ self.assertEqual(foo['title'], 'Foo')
278
+ self.assertEqual(bar['title'], 'Bar')
279
+
280
+ def test_do_make_menus_with_submenu(self):
281
+ test_menus = [
282
+ {
283
+ 'title': "First Menu",
284
+ 'type': 'menu',
285
+ 'items': [
286
+ {
287
+ 'title': "First Submenu",
288
+ 'type': 'menu',
289
+ 'items': [
290
+ {'title': "Foo", 'url': '#'},
291
+ ],
292
+ },
293
+ {
294
+ 'title': "Second Submenu",
295
+ 'type': 'menu',
296
+ 'items': [
297
+ {'title': "Bar", 'url': '#'},
298
+ ],
299
+ },
300
+ ],
301
+ },
302
+ ]
303
+
304
+ def is_allowed(request, item):
305
+ if item.get('title') == 'Bar':
306
+ return False
307
+ return True
308
+
309
+ with patch.object(self.handler, 'make_menus', return_value=test_menus):
310
+ with patch.object(self.handler, '_is_allowed', side_effect=is_allowed):
311
+ menus = self.handler.do_make_menus(self.request)
312
+
313
+ # first submenu remains, second is pruned
314
+ menu = menus[0]
315
+ self.assertEqual(len(menu['items']), 1)
316
+ submenu = menu['items'][0]
317
+ self.assertEqual(submenu['type'], 'submenu')
318
+ self.assertEqual(submenu['title'], 'First Submenu')
319
+ self.assertEqual(len(submenu['items']), 1)
320
+ item = submenu['items'][0]
321
+ self.assertEqual(item['title'], 'Foo')
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes