├── .gitignore
├── .travis.yml
├── CHANGES.rst
├── LICENSE
├── MANIFEST.in
├── README.rst
├── conftest.py
├── navutils
├── __init__.py
├── __version__.py
├── apps.py
├── breadcrumbs.py
├── context_processors.py
├── menu.py
├── mixins.py
├── settings.py
├── templates
│ └── navutils
│ │ ├── breadcrumbs.html
│ │ ├── crumb.html
│ │ ├── menu.html
│ │ └── node.html
├── templatetags
│ ├── __init__.py
│ └── navutils_tags.py
└── views.py
├── requirements
├── test.txt
└── travis.txt
├── setup.py
├── tests
├── __init__.py
├── test_app
│ ├── __init__.py
│ ├── models.py
│ ├── templates
│ │ └── test_app
│ │ │ ├── base.html
│ │ │ └── test_node.html
│ ├── urls.py
│ └── views.py
├── test_breadcrumbs.py
├── test_menu.py
├── test_mixins.py
└── test_render_nested.py
└── tox.ini
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | *.pyc
3 | *.egg-info/
4 | .tox/
5 | .cache/
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 |
3 | matrix:
4 | include:
5 | - python: 3.7
6 | env: TOX_ENV=py36-django-22
7 | - python: 3.7
8 | env: TOX_ENV=py37-django-32
9 | - python: 3.8
10 | env: TOX_ENV=py38-django-32
11 | - python: 3.9
12 | env: TOX_ENV=py39-django-32
13 | - python: 3.8
14 | env: TOX_ENV=py38-django-master
15 | - python: 3.9
16 | env: TOX_ENV=py39-django-master
17 | fast_finish: true
18 | allow_failures:
19 | - env: TOX_ENV=py38-django-master
20 | - env: TOX_ENV=py39-django-master
21 |
22 | # command to install dependencies, e.g. pip install -r requirements.txt --use-mirrors
23 | # install: pip install pytest-django
24 |
25 | # command to run tests using coverage, e.g. python setup.py test
26 | script: tox -e $TOX_ENV
27 |
28 | install:
29 | - pip install -r requirements/travis.txt
30 |
31 | after_success:
32 | - codecov -e TOX_ENV
33 |
--------------------------------------------------------------------------------
/CHANGES.rst:
--------------------------------------------------------------------------------
1 | 0.7 (22/02/2019):
2 |
3 | - Allow for empty URL value (#11 by @wgordon17)
4 | - Document global template override (#12 by @wgordon17)
5 | - Process arbitrary template tags in nodes (#13 by @wgordon17)
6 | - Updated test matrix (Django 2.1, Python 3.7, dropped Django 1.10)
7 |
8 | 0.6 (18/01/2018):
9 |
10 | - Django 2 compatibility (#9 by @thperret)
11 | - Dropped django 1.8 and 1.9 compatibility (#9 by @thperret)
12 |
13 | 0.5.5 (05/09/2017):
14 |
15 | - Django 1.11 compatibility (#5 by @jan-lugfl)
16 |
17 | 0.5.4 (29/09/2015):
18 |
19 | - Triggered autodiscover in navutils, fix #1 failing example
20 |
21 | 0.5.3 (21/08/2015):
22 |
23 | - Added microdata for better breadcrumb handling by search engines
24 |
25 | 0.5.2 (22/07/2015):
26 |
27 | - Fixed context that wasn't passed from menu to node
28 |
29 | 0.5.1 (22/07/2015):
30 |
31 | - Updated ``PassTestNode`` with context argument
32 |
33 | 0.5 (22/07/2015):
34 |
35 | - ``Node.is_viewable_by`` now takes a ``context`` argument
36 |
37 | 0.4 (19/06/2015):
38 |
39 | - ``Menu`` and ``Node`` now accept extra context
40 |
41 | 0.3 (03/06/2015):
42 |
43 | - Added ``MenuMixin`` for handling current menu node
44 |
45 | 0.2 (24/05/2015):
46 |
47 | - Added PermissionNode for checking against a single permission
48 | - Added AllPermissionsNode for checking against all permissions in set of permission
49 | - Added AnylPermissionsNode for checking against any permissions in set of permission
50 | - Added PassTestNode for providing a custom check
51 |
52 | 0.1.4 (21/05/2015):
53 |
54 | - removed useless print statement
55 |
56 | 0.1.3 (21/05/2015):
57 |
58 | - added MANIFEST (templates were not included)
59 |
60 | 0.1.2 (21/05/2015):
61 |
62 | - added MANIFEST (templates were not included)
63 |
64 | 0.1.1 (21/05/2015):
65 |
66 | - Added changelog
67 | - Fixed IndexError when building seo_title
68 |
69 |
70 | 0.1.0 (20/05/2015):
71 |
72 | - initial release
73 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) Eliot Berriot and individual contributors.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | 1. Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | 2. Redistributions in binary form must reproduce the above copyright
11 | notice, this list of conditions and the following disclaimer in the
12 | documentation and/or other materials provided with the distribution.
13 |
14 | 3. Neither the name of Django-navutils nor the names of its contributors may be used
15 | to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst
2 | include LICENSE
3 | include CHANGES.rst
4 | recursive-include navutils/templates *.html
5 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | Django-navutils
2 | ~~~~~~~~~~~~~~~
3 |
4 | **Note**: this package is still in beta. It has been successfully used in a few projects of my own. However, API may be subject to backward incompatible changes until the first major version is released.
5 |
6 | Django-navutils is a lightweight package for handling menu and breadcrumbs inside your django project.
7 |
8 | .. contents::
9 | :local:
10 | :depth: 2
11 |
12 | Features
13 | ========
14 |
15 | - No database involved (unless you want it): menus and breadcrumbs are plain old python code
16 | - Highly customizable
17 | - Conditionnal menu items display: you want to show a menu link to authenticated users only ? Anonymous ? Staff members ? A custom criteria ? You're covered !
18 | - i18n-friendly: you can rely on usual django translation mechanisms
19 | - Unlimited menus
20 | - Semi-automatic current menu node detection
21 |
22 | Requirements
23 | ============
24 |
25 | - Python >= 2.7 or >= 3.3
26 | - Django >= 1.7
27 |
28 | The menu system may be integrated in any project, but the breadcrumbs part requires
29 | that you use class-based views.
30 |
31 | Install
32 | =======
33 |
34 | Package is available on pip and can be installed via ``pip install django-navutils``.
35 |
36 | You'll also have to add ``navutils`` to your ``settings.INSTALLED_APPS``
37 |
38 | Also add the following to ``settings.CONTEXT_PROCESSORS``:
39 |
40 | .. code:: python
41 |
42 | CONTEXT_PROCESSORS = (
43 | # ...
44 | 'navutils.context_processors.menus',
45 | )
46 |
47 | Usage
48 | =====
49 |
50 | Menus
51 | *****
52 |
53 | Navutils represents menus using ``Menu`` and ``Node`` instances, each menu being a collection of
54 | node instances representing a menu link. Nodes may have children, which are also ``Node`` instances.
55 |
56 | Let's see a minimal example.
57 |
58 | ``yourapp/menu.py``:
59 |
60 | .. code:: python
61 |
62 | from navutils import menu
63 |
64 | main_menu = menu.Menu('main')
65 | menu.register(main_menu)
66 |
67 | # will be shown to everybody
68 | blog = menu.Node(id='blog', label='Blog', pattern_name='blog:index')
69 | main_menu.register(blog)
70 |
71 | # nodes created with a pattern_name argument will use django reverse
72 | assert blog.get_url() == '/blog'
73 |
74 | # if you want to disable reversion, use the url argument
75 | django = menu.Node(id='django',
76 | label='Django project',
77 | url='http://djangoproject.com',
78 | link_attrs={'target': '_blank'})
79 |
80 | # Each node instance can accept an arbitrary number of children
81 | blog.add(
82 | menu.Node(id='last_entries',
83 | label='Last entries',
84 | pattern_name='blog:last_entries')
85 | )
86 | blog.add(
87 | menu.Node(id='archives', label='Archives', pattern_name='blog:archives')
88 | )
89 |
90 | # will be shown to anonymous users only
91 | login = menu.AnonymousNode(id='login',
92 | label='Login',
93 | pattern_name='accounts_login',
94 | link_attrs={'class': 'big-button'})
95 | main_menu.register(login)
96 |
97 | # will be shown to authenticated users only
98 | logout = menu.AuthenticatedNode(id='logout',
99 | label='Logout',
100 | pattern_name='accounts_logout')
101 | main_menu.register(logout)
102 |
103 |
104 | ``yourapp/templates/index.html``::
105 |
106 | {% load navutils_tags %}
107 | {% render_menu menu=menus.main user=request.user %}
108 |
109 | For an anonymous user, this would output something like:
110 |
111 | .. code:: html
112 |
113 |
114 |
133 |
134 |
135 |
136 | You can also directly set children nodes on parent instanciation with the ``children`` argument:
137 |
138 | .. code:: python
139 |
140 | user = menu.Node(
141 | id='user',
142 | label='Greetings',
143 | pattern_name='user:dashboard',
144 | children=[
145 | menu.Node(id='logout', label='Logout', pattern_name='user:logout'),
146 |
147 | # you can nest children indefinitely
148 | menu.Node(
149 | id='settings',
150 | label='Settings',
151 | pattern_name='user:settings',
152 | children = [
153 | menu.Node(id='newsletter',
154 | label='Newsletter',
155 | pattern_name='user:settings:newsletter')
156 | ],
157 | ),
158 | ]
159 | )
160 |
161 | Nodes can be customized in many ways:
162 |
163 | .. code:: python
164 |
165 | heavily_customized_node = menu.Node(
166 | 'customized',
167 | 'My custom menu', # Supports arbitrary template values as well
168 | # like {{ request.user }}
169 | url='#', # Supports arbitrary template values as well
170 | # like {{ request.user }}
171 |
172 | # a custom CSS class that will be applied to the node on rendering
173 | css_class='custom-class',
174 |
175 | # the title attribute
176 | title='click me!',
177 |
178 | # a path to a custom template for rendering the node
179 | # it's also possible to globally specify a custom template by naming
180 | # your template '/templates/navutils/node.html'
181 | template='myapp/menu/mynode.html',
182 |
183 | # extra context you can use in your node template
184 | context={'foo': 'bar'},
185 |
186 | # a dict of attributes that will be applied as HTML attributes on the
187 | attrs = {'style': 'background-color: white;'}
188 |
189 | # a dict of attributes that will be applied as HTML attributes on the
190 | link_attrs = {'target': '_blank', 'data-something': 'fancy-stuff'}
191 | )
192 |
193 | Current node
194 | ------------
195 |
196 | You'll probably want to highlight the current node in some way. Navutils provide
197 | a view mixin you an inherit from in order to achieve this.
198 |
199 | Assuming the following menu:
200 |
201 | .. code:: python
202 |
203 | from navutils import menu
204 |
205 | main_menu = menu.Menu(id='main')
206 | menu.register(main_menu)
207 |
208 | login = menu.Node(id='login', label='Login', pattern_name='account_login')
209 | main_menu.register(login)
210 |
211 |
212 | You can bind a view to a menu node with the following code:
213 |
214 | .. code:: python
215 |
216 | from navutils import MenuMixin
217 |
218 | class Login(MenuMixin, TemplateView):
219 | current_menu_item = 'login'
220 |
221 |
222 | Under the hood, the mixin will pass the value to the context and a `current` class will be added
223 | to the login node if the view is displayed. Note that you can achieve the same result
224 | with django function-based views, as long as you manually pass the node identifier in the context,
225 | under the `current_menu_item` key.
226 |
227 | Node reference
228 | --------------
229 |
230 | Navutils provide a few node subclasses that address common use cases.
231 |
232 | Node
233 | ++++
234 |
235 | The base Node type, will be displayed to anybody.
236 |
237 | AnonymousNode
238 | +++++++++++++
239 |
240 | Displayed to anonymous users only.
241 |
242 | AuthenticatedNode
243 | +++++++++++++++++
244 |
245 | Displayd to authenticated users only.
246 |
247 | StaffNode
248 | +++++++++
249 |
250 | Displayed to staff users/superusers only.
251 |
252 | PermissionNode
253 | ++++++++++++++
254 |
255 | Displayed to users that have the given permission. Usage:
256 |
257 | .. code:: python
258 |
259 | vip_node = menu.PermissionNode('vip',
260 | label='VIP Area',
261 | pattern_name='vip:index',
262 | permission='access_vip_area')
263 |
264 | AllPermissionsNode
265 | ++++++++++++++++++
266 |
267 | Displayed to users that match a list of permission. Usage:
268 |
269 | .. code:: python
270 |
271 | permissions = ['myapp.access_vip_area', 'myapp.drink_champagne']
272 | champagne_node = menu.AllPermissionsNode('champagne',
273 | label='Champagne!',
274 | pattern_name='vip:champagne',
275 | permissions=permissions)
276 |
277 | AnyPermissionsNode
278 | ++++++++++++++++++
279 |
280 | Displayed to users that match any given permission. Usage:
281 |
282 | .. code:: python
283 |
284 | permissions = ['myapp.can_party', 'myapp.can_have_fun']
285 | have_a_good_time = menu.AnyPermissionsNode('good-time',
286 | label='Have a good time',
287 | pattern_name='good_time',
288 | permissions=permissions)
289 |
290 |
291 | PassTestNode
292 | ++++++++++++
293 |
294 | Displayed to users that match a custom test. Usage:
295 |
296 | .. code:: python
297 |
298 | def can_drink_alcohol(user, context):
299 | return user.age >= 21 or user.looks_mature_for_his_age
300 |
301 | drink_alcohol = menu.PassTestNode('drink',
302 | label='Have a beer',
303 | pattern_name='beer',
304 | test=can_drink_alcohol)
305 |
306 | If it's not enough, you can also override the default templates:
307 |
308 | - ``navutils/menu.html`` : the menu wrapper that loop through the nodes
309 | - ``navutils/node.html`` : called for displaying each node instance
310 |
311 | And of course, you're free to create your own sub-classes.
312 |
313 | Breadcrumbs
314 | ***********
315 |
316 | Breadcrumbs are set up into views, and therefore can only be used with class-based views.
317 |
318 | First of all, you'll probably want to define a base mixin for all your views:
319 |
320 | .. code:: python
321 |
322 | from navutils import BreadcrumbsMixin, Breadcrumb
323 |
324 | class BaseMixin(BreadcrumbsMixin):
325 | def get_breadcrumbs(self):
326 | breadcrumbs = super(BaseMixin, self).get_breadcrumbs()
327 | breadcrumbs.append(Breadcrumb('Home', url='/'))
328 | return breadcrumbs
329 |
330 | Then, you can inherit from this view everywhere:
331 |
332 | .. code:: python
333 |
334 | # breadcrumbs = Home > Blog
335 | class BlogView(BaseMixin):
336 | title = 'Blog'
337 |
338 |
339 | # breadcrumbs = Home > Logout
340 | class LogoutView(BaseMixin):
341 | title = 'Logout'
342 |
343 |
344 | By default, the last element of the breadcrumb is deduced from the ``title`` attribute of the view.
345 | However, for a complex hierarchy, you are free to override the ``get_breadcrumbs`` method:
346 |
347 | .. code:: python
348 |
349 | # you can trigger url reversing via pattern_name, as for menu nodes
350 | class BlogMixin(BaseMixin)
351 | def get_breadcrumbs(self):
352 | breadcrumbs = super(BlogMixin, self).get_breadcrumbs()
353 | breadcrumbs.append(Breadcrumb('Blog', pattern_name='blog:index'))
354 | return breadcrumbs
355 |
356 |
357 | # breadcrumbs = Home > Blog > Last entries
358 | class BlogIndex(BlogMixin):
359 | title = 'Last entries'
360 |
361 |
362 | # for dynamic titles, just override the get_title method
363 | # breadcrumbs = Home > Blog > My category name
364 | class CategoryDetail(BlogMixin, DetailView):
365 |
366 | model = Category
367 |
368 | def get_title(self):
369 | # assuming your Category model has a title field
370 | return self.object.title
371 |
372 |
373 | The last step is to render the breadcrumbs in your template. The provided mixin takes
374 | care with passing data in the context, so all you need is::
375 |
376 | {% load navutils_tags %}
377 |
378 | {% render_breadcrumbs breadcrumbs %}
379 |
380 | The breadcrumbs part of navutils is bundled with two templates, feel free to override them:
381 |
382 | - ``navutils/breadcrumbs.html``: the breadcrumbs wrapper
383 | - ``navutils/crumb.html``: used to render each crumb
384 |
385 | That's it !
386 |
387 | Changelog
388 | =========
389 |
390 | See `CHANGES.rst
391 | `_.
392 |
393 | License
394 | =======
395 |
396 | Project is licensed under BSD license.
397 |
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 |
2 |
3 | def pytest_configure():
4 | from django.conf import settings
5 | import sys
6 |
7 | try:
8 | import django # NOQA
9 | except ImportError:
10 | print("Error: missing test dependency:")
11 | print(" django library is needed to run test suite")
12 | print(" you can install it with 'pip install django'")
13 | print(" or use tox to automatically handle test dependencies")
14 | sys.exit(1)
15 | # Dynamically configure the Django settings with the minimum necessary to
16 | # get Django running tests.
17 | settings.configure(
18 | INSTALLED_APPS=[
19 | 'django.contrib.auth',
20 | 'django.contrib.contenttypes',
21 | 'django.contrib.admin',
22 | 'django.contrib.sessions',
23 | 'navutils',
24 | 'tests.test_app',
25 | ],
26 | MIDDLEWARE_CLASSES=(
27 | 'django.contrib.sessions.middleware.SessionMiddleware',
28 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
29 | 'django.contrib.messages.middleware.MessageMiddleware',
30 | ),
31 | # Django replaces this, but it still wants it. *shrugs*
32 | DATABASE_ENGINE='django.db.backends.sqlite3',
33 | DATABASES={
34 | 'default': {
35 | 'ENGINE': 'django.db.backends.sqlite3',
36 | 'NAME': ':memory:',
37 | }
38 | },
39 | MEDIA_ROOT='/tmp/django_extensions_test_media/',
40 | MEDIA_PATH='/media/',
41 | ROOT_URLCONF='tests.test_app.urls',
42 | DEBUG=True,
43 | TEMPLATE_DEBUG=True,
44 | TEMPLATES=[
45 | {
46 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
47 | 'DIRS': [
48 | # insert your TEMPLATE_DIRS here
49 | ],
50 | 'APP_DIRS': True,
51 | 'OPTIONS': {
52 | 'context_processors': [
53 | # Insert your TEMPLATE_CONTEXT_PROCESSORS here or use this
54 | # list if you haven't customized them:
55 | 'django.contrib.auth.context_processors.auth',
56 | 'django.template.context_processors.debug',
57 | 'django.template.context_processors.i18n',
58 | 'django.template.context_processors.media',
59 | 'django.template.context_processors.static',
60 | 'django.template.context_processors.tz',
61 | 'django.contrib.messages.context_processors.messages',
62 | ],
63 | },
64 | },
65 | ],
66 | SECRET_KEY = 'not secure only for testing',
67 | )
68 |
--------------------------------------------------------------------------------
/navutils/__init__.py:
--------------------------------------------------------------------------------
1 | import django
2 |
3 | from .breadcrumbs import Breadcrumb, BreadcrumbsMixin
4 | from .views import MenuMixin
5 |
6 | from . import menu
7 |
8 | if django.VERSION < (3, 2):
9 | default_app_config = 'navutils.apps.App'
10 |
--------------------------------------------------------------------------------
/navutils/__version__.py:
--------------------------------------------------------------------------------
1 | __version__ = "0.7"
2 |
--------------------------------------------------------------------------------
/navutils/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig, apps
2 |
3 |
4 | class App(AppConfig):
5 | name = 'navutils'
6 |
7 | def ready(self):
8 | from . import menu
9 | menu.registry.autodiscover((a.name for a in apps.get_app_configs()))
10 |
--------------------------------------------------------------------------------
/navutils/breadcrumbs.py:
--------------------------------------------------------------------------------
1 | try:
2 | # Django 1.10+
3 | from django.urls import reverse
4 | except ImportError:
5 | from django.core.urlresolvers import reverse
6 |
7 |
8 | class Breadcrumb(object):
9 | def __init__(self, label, pattern_name=None, url=None, title=None, css_class=None,
10 | reverse_kwargs=[], **kwargs):
11 | if pattern_name and url:
12 | raise ValueError('Breadcrumb accepts either a url or a pattern_name arg, but not both')
13 | if not pattern_name and not url:
14 | raise ValueError('Breadcrumb needs either a url or a pattern_name arg')
15 |
16 | self.pattern_name = pattern_name
17 | self.url = url
18 | self.label = label
19 | self.css_class = css_class
20 | self.reverse_kwargs = reverse_kwargs
21 |
22 | def get_url(self, **kwargs):
23 | if self.pattern_name:
24 | expected_kwargs = {
25 | key: value for key, value in kwargs.items()
26 | if key in self.reverse_kwargs
27 | }
28 | return reverse(self.pattern_name, kwargs=expected_kwargs)
29 | return self.url
30 |
31 |
32 | class BreadcrumbsMixin(object):
33 |
34 | title = ''
35 | current_url = None
36 |
37 | def get_title(self):
38 | return self.title
39 |
40 | def get_current_url(self):
41 | return self.current_url
42 |
43 | def get_context_data(self, **kwargs):
44 | context = super(BreadcrumbsMixin, self).get_context_data(**kwargs)
45 | breadcrumbs = self.get_breadcrumbs()
46 |
47 | title = self.get_title()
48 | seo_title = title
49 |
50 | # auto bread-crumb from title
51 | if title:
52 | if breadcrumbs and breadcrumbs[-1].label != title:
53 | url = self.get_current_url() or '#'
54 | breadcrumbs.append(Breadcrumb(url=url, reverse=False, label=title))
55 |
56 | try:
57 | # append parent breadcrumb title for better SEO
58 | if seo_title and breadcrumbs[-2] != breadcrumbs[0] and breadcrumbs[-2].label:
59 | seo_title += ' - {0}'.format(breadcrumbs[-2].label)
60 | except IndexError:
61 | pass
62 |
63 | context['breadcrumbs'] = breadcrumbs
64 | context['title'] = title
65 | context['seo_title'] = seo_title
66 |
67 | return context
68 |
69 | def get_breadcrumbs(self):
70 | return []
71 |
--------------------------------------------------------------------------------
/navutils/context_processors.py:
--------------------------------------------------------------------------------
1 | from . import menu
2 |
3 |
4 | def menus(*args, **kwargs):
5 | return {'menus': menu.registry}
6 |
--------------------------------------------------------------------------------
/navutils/menu.py:
--------------------------------------------------------------------------------
1 | try:
2 | # Django 1.10+
3 | from django.urls import reverse
4 | except ImportError:
5 | from django.core.urlresolvers import reverse
6 |
7 | from persisting_theory import Registry
8 |
9 |
10 | class Menus(Registry):
11 | """ Keep a reference to all menus"""
12 | look_into = 'menu'
13 |
14 | def prepare_name(self, data, name=None):
15 | return data.id
16 |
17 | registry = Menus()
18 | register = registry.register
19 |
20 |
21 | class Menu(Registry):
22 | """A collection of nodes"""
23 | def __init__(self, id, *args, **kwargs):
24 | self.id = id
25 | self.css_class = kwargs.pop('css_class', None)
26 | self.template = kwargs.pop('template', 'navutils/menu.html')
27 | self.context = kwargs.pop('context', {})
28 | super(Menu, self).__init__(*args, **kwargs)
29 |
30 | def prepare_name(self, data, name=None):
31 | return data.id
32 |
33 | def get_context(self, context):
34 | context.update(self.context)
35 | return context
36 |
37 | class Node(object):
38 |
39 | parent = None
40 |
41 | def __init__(self, id, label, pattern_name=None, url=None, divider=False, weight=0, title=None,
42 | template='navutils/node.html', children=[], css_class=None, submenu_css_class=None,
43 | reverse_kwargs=[], attrs={}, link_attrs={}, context={}, **kwargs):
44 | """
45 | :param str id: a unique identifier for further retrieval
46 | :param str label: a label for the node, that will be displayed in templates
47 | :param str pattern_name: the name of a django url, such as `myapp:index` to use
48 | as a link for the node. It will be automatically reversed.
49 | :param str url: a URL to use as a link for the node
50 | :parma bool divider: node is a divider - url and pattern_name are ignored; could be header if label is provided
51 | :param int weight: The importance of the node. Higher is more\
52 | important, default to ``0``.
53 | :param list reverse_kwargs: A list of strings that the pattern_name will\
54 | accept when reversing. Defaults to ``[]``
55 | :param list children: A list of children :py:class:`Node` instances\
56 | that will be considered as submenus of this instance.\ You can also pass\
57 | a callable that will return an iterable of menu nodes.
58 | Defaults to ``[]``.
59 | :param str css_class: a CSS class that will be applied to the node when
60 | rendering
61 | :param str submenu_css_class: a CSS class that will be applied to any
62 | submenu's in the node when rendering
63 | :param str template: the template that will be used to render the node.\
64 | defaults to `navutils/menu/node.html`
65 | :param dict node_attrs: a dictionnary of attributes to apply to the node
66 | html
67 | :param dict link_attrs: a dictionnary of attributes to apply to the node
68 | link html
69 | """
70 | if pattern_name and url:
71 | raise ValueError('MenuNode accepts either a url or a pattern_name arg, but not both')
72 | if pattern_name is None and url is None and not divider:
73 | raise ValueError('MenuNode needs either a url or a pattern_name arg')
74 | if divider and (pattern_name or url):
75 | raise ValueError('MenuNode divider should have neither url nor pattern_name args')
76 |
77 | self._id = id
78 | self.pattern_name = pattern_name
79 | self.url = url
80 | self.is_divider = divider
81 | self.label = label
82 | self.weight = weight
83 | self.template = template
84 | self.css_class = css_class
85 | self.reverse_kwargs = reverse_kwargs
86 | self.link_attrs = link_attrs
87 | self.attrs = attrs
88 | self.context = context
89 | self.kwargs = kwargs
90 |
91 | if 'class' in self.attrs:
92 | raise ValueError('CSS class is handled via the css_class argument, don\'t use attrs for this purpose')
93 |
94 | self._children = children
95 |
96 | if not hasattr(self._children, '__call__'):
97 | self._children = []
98 | for node in children:
99 | self.add(node)
100 |
101 | def get_context(self, context):
102 | context.update(self.context)
103 | return context
104 |
105 | @property
106 | def children(self):
107 | if hasattr(self._children, '__call__'):
108 | return self._children()
109 | return self._children
110 |
111 | def get_url(self, **kwargs):
112 | """
113 | :param kwargs: a dictionary of values that will be used for reversing,\
114 | if the corresponding key is present in :py:attr:`self.reverse_kwargs\
115 | `
116 | :return: The target URL of the node, after reversing (if needed)
117 | """
118 | if self.pattern_name:
119 | expected_kwargs = {
120 | key: value for key, value in kwargs.items()
121 | if key in self.reverse_kwargs
122 | }
123 | return reverse(self.pattern_name, kwargs=expected_kwargs)
124 | return self.url
125 |
126 | def add(self, node):
127 | """
128 | Add a new node to the instance children and sort them by weight.
129 |
130 | :param node: A node instance
131 | """
132 | node.parent = self
133 | self._children.append(node)
134 | self._children = sorted(
135 | self._children,
136 | key=lambda i: i.weight,
137 | reverse=True
138 | )
139 |
140 | def is_viewable_by(self, user, context={}):
141 | return True
142 |
143 | @property
144 | def id(self):
145 | if self.parent:
146 | return '{0}:{1}'.format(self.parent.id, self._id)
147 | return self._id
148 |
149 | @property
150 | def depth(self):
151 | return 0 if not self.parent else self.parent.depth + 1
152 |
153 | def __repr__(self):
154 | return ''.format(self.label)
155 |
156 | def is_current(self, current):
157 | return self.id == current
158 |
159 | def has_current(self, current, viewable_children):
160 | return any([child.is_current(current) for child in viewable_children]) or any([child.has_current(current,child.children) for child in viewable_children])
161 |
162 |
163 |
164 | class AnonymousNode(Node):
165 | """Only viewable by anonymous users"""
166 | def is_viewable_by(self, user, context={}):
167 | try:
168 | return not user.is_authenticated()
169 | except TypeError:
170 | # Django 2.0+
171 | return not user.is_authenticated
172 |
173 |
174 | class AuthenticatedNode(Node):
175 | """Only viewable by authenticated users"""
176 | def is_viewable_by(self, user, context={}):
177 | try:
178 | return user.is_authenticated()
179 | except TypeError:
180 | # Django 2.0+
181 | return user.is_authenticated
182 |
183 |
184 | class StaffNode(AuthenticatedNode):
185 | """Only viewable by staff members / admins"""
186 |
187 | def is_viewable_by(self, user, context={}):
188 | return user.is_staff or user.is_superuser
189 |
190 |
191 | class PermissionNode(Node):
192 | """Require that user has given permission to display"""
193 |
194 | def __init__(self, *args, **kwargs):
195 | self.permission = kwargs.pop('permission')
196 | super(PermissionNode, self).__init__(*args, **kwargs)
197 |
198 | def is_viewable_by(self, user, context={}):
199 | return user.has_perm(self.permission)
200 |
201 |
202 | class AllPermissionsNode(Node):
203 | """Require user has all given permissions to display"""
204 |
205 | def __init__(self, *args, **kwargs):
206 | self.permissions = kwargs.pop('permissions')
207 | super(AllPermissionsNode, self).__init__(*args, **kwargs)
208 |
209 | def is_viewable_by(self, user, context={}):
210 | return all([user.has_perm(perm) for perm in self.permissions])
211 |
212 |
213 |
214 | class AnyPermissionsNode(Node):
215 | """Require user has one of the given permissions to display"""
216 |
217 | def __init__(self, *args, **kwargs):
218 | self.permissions = kwargs.pop('permissions')
219 | super(AnyPermissionsNode, self).__init__(*args, **kwargs)
220 |
221 | def is_viewable_by(self, user, context={}):
222 | for permission in self.permissions:
223 | has_perm = user.has_perm(permission)
224 | if has_perm:
225 | return True
226 | return False
227 |
228 |
229 | class PassTestNode(Node):
230 | def __init__(self, *args, **kwargs):
231 | self.test = kwargs.pop('test')
232 | super(PassTestNode, self).__init__(*args, **kwargs)
233 |
234 | def is_viewable_by(self, user, context={}):
235 | return self.test(user, context=context)
236 |
--------------------------------------------------------------------------------
/navutils/mixins.py:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | class TitleMixin(object):
5 | title = None
6 |
7 | def get_title(self):
8 | return self.title
9 |
10 | def get_context_data(self, **kwargs):
11 | context = super(TitleMixin, self).get_context_data(**kwargs)
12 | context['title'] = self.get_title()
13 | return context
14 |
15 |
16 | class DescriptionMixin(object):
17 | description = None
18 |
19 | def get_description(self):
20 | return self.description
21 |
22 | def get_context_data(self, **kwargs):
23 | context = super(DescriptionMixin, self).get_context_data(**kwargs)
24 | context['description'] = self.get_description()
25 | return context
26 |
--------------------------------------------------------------------------------
/navutils/settings.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 |
3 |
4 | DEFAULT_MENU_CONFIG = {
5 | 'CURRENT_MENU_ITEM_CLASS': 'current',
6 | 'CURRENT_MENU_ITEM_PARENT_CLASS': 'has-current',
7 | }
8 |
9 | existing_conf = getattr(settings, 'NAVUTILS_MENU_CONFIG', {})
10 | DEFAULT_MENU_CONFIG.update(existing_conf)
11 |
12 | NAVUTILS_MENU_CONFIG = DEFAULT_MENU_CONFIG
13 |
--------------------------------------------------------------------------------
/navutils/templates/navutils/breadcrumbs.html:
--------------------------------------------------------------------------------
1 | {% load navutils_tags %}
2 |
3 | {% if crumbs %}
4 |
5 | {% for crumb in crumbs %}
6 | {% if forloop.last %}
7 | {% render_crumb crumb last=True %}
8 | {% else %}
9 | {% render_crumb crumb %}
10 | {% endif %}
11 | {% endfor %}
12 |
13 | {% endif %}
14 |
--------------------------------------------------------------------------------
/navutils/templates/navutils/crumb.html:
--------------------------------------------------------------------------------
1 | {% load navutils_tags %}
2 |
3 |
4 | {% block crumb_label %}{% render_nested crumb.label %}{% endblock %}
5 |
6 |
--------------------------------------------------------------------------------
/navutils/templates/navutils/menu.html:
--------------------------------------------------------------------------------
1 | {% load navutils_tags %}
2 |
3 |
8 |
--------------------------------------------------------------------------------
/navutils/templates/navutils/node.html:
--------------------------------------------------------------------------------
1 | {% load navutils_tags %}
2 |
3 |
17 |
--------------------------------------------------------------------------------
/navutils/templatetags/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/agateblue/django-navutils/27b0cc56641ad60dcf6ab080714657bc207d2c3b/navutils/templatetags/__init__.py
--------------------------------------------------------------------------------
/navutils/templatetags/navutils_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from navutils import settings
3 |
4 | register = template.Library()
5 |
6 |
7 | @register.simple_tag(takes_context=True)
8 | def render_menu(context, menu, **kwargs):
9 |
10 | # menu = kwargs.get('menu', context.get('menu'))
11 | # if not menu:
12 | # raise ValueError('Missing menu argument')
13 |
14 | user = kwargs.get('user', context.get('user', getattr(context.get('request', object()), 'user', None)))
15 | if not user:
16 | raise ValueError('missing user parameter')
17 |
18 | max_depth = kwargs.get('max_depth', context.get('max_depth', 999))
19 | viewable_nodes = [node for node in menu.values() if node.is_viewable_by(user, context)]
20 |
21 | # Also sort parent nodes if them have weight
22 | viewable_nodes = sorted(viewable_nodes,key=lambda i: i.weight, reverse=True)
23 |
24 | if not viewable_nodes:
25 | return ''
26 |
27 | t = template.loader.get_template(menu.template)
28 | c = {
29 | 'menu': menu,
30 | 'viewable_nodes': viewable_nodes,
31 | 'user': user,
32 | 'max_depth': max_depth,
33 | 'current_menu_item': kwargs.get('current_menu_item', context.get('current_menu_item')),
34 | 'menu_config': settings.NAVUTILS_MENU_CONFIG
35 | }
36 | context.update(c)
37 | final_context = menu.get_context(context)
38 | try:
39 | return t.render(final_context)
40 | except TypeError:
41 | # Django 2.0+
42 | return t.render(final_context.flatten())
43 |
44 | @register.simple_tag(takes_context=True)
45 | def render_node(context, node, **kwargs):
46 | # node = kwargs.get('node', context.get('node'))
47 | # if not node:
48 | # raise ValueError('Missing node argument')
49 |
50 |
51 | user = kwargs.get('user', context.get('user', getattr(context.get('request', object()), 'user', None)))
52 | if not user:
53 | raise ValueError('missing user parameter')
54 |
55 | if not node.is_viewable_by(user, context):
56 | return ''
57 |
58 | current = kwargs.get('current_menu_item', context.get('current_menu_item'))
59 | max_depth = kwargs.get('max_depth', context.get('max_depth', 999))
60 | start_depth = kwargs.get('start_depth', context.get('start_depth', node.depth))
61 | current_depth = kwargs.get('current_depth', context.get('current_depth', node.depth - start_depth))
62 |
63 | viewable_children = []
64 | if current_depth + 1 <= max_depth:
65 | for child in node.children:
66 | if child.is_viewable_by(user, context):
67 | viewable_children.append(child)
68 |
69 | t = template.loader.get_template(node.template)
70 |
71 | c = {
72 | 'is_current': node.is_current(current),
73 | 'has_current': node.has_current(current, viewable_children),
74 | 'current_menu_item': current,
75 | 'node': node,
76 | 'viewable_children': viewable_children,
77 | 'user': user,
78 | 'max_depth': max_depth,
79 | 'current_depth': current_depth,
80 | 'start_depth': start_depth,
81 | 'menu_config': settings.NAVUTILS_MENU_CONFIG
82 | }
83 | context.update(c)
84 | final_context = node.get_context(context)
85 |
86 | try:
87 | return t.render(final_context)
88 | except TypeError:
89 | # Django 2.0+
90 | return t.render(final_context.flatten())
91 |
92 |
93 | @register.simple_tag(takes_context=True)
94 | def render_crumb(context, crumb, **kwargs):
95 |
96 | t = template.loader.get_template('navutils/crumb.html')
97 |
98 | return t.render({
99 | 'crumb': crumb,
100 | 'last': kwargs.get('last', False),
101 | })
102 |
103 | @register.simple_tag(takes_context=True)
104 | def render_breadcrumbs(context, crumbs, **kwargs):
105 |
106 | t = template.loader.get_template('navutils/breadcrumbs.html')
107 |
108 | return t.render({
109 | 'crumbs': crumbs,
110 | })
111 |
112 | @register.simple_tag(takes_context=True)
113 | def render_nested(context, template_text):
114 | # create template from text
115 | tpl = template.Template(template_text)
116 | return tpl.render(context)
117 |
--------------------------------------------------------------------------------
/navutils/views.py:
--------------------------------------------------------------------------------
1 |
2 |
3 | class MenuMixin(object):
4 | current_menu_item = None
5 |
6 | def get_current_menu_item(self):
7 | return self.current_menu_item
8 |
9 | def get_context_data(self, **kwargs):
10 | context = super(MenuMixin, self).get_context_data(**kwargs)
11 | context['current_menu_item'] = self.get_current_menu_item()
12 | return context
13 |
--------------------------------------------------------------------------------
/requirements/test.txt:
--------------------------------------------------------------------------------
1 | pytest-django
2 |
--------------------------------------------------------------------------------
/requirements/travis.txt:
--------------------------------------------------------------------------------
1 | coverage==4.3.4
2 | mock>=1.0.1
3 | flake8>=2.1.0
4 | tox>=1.7.0
5 | codecov>=2.0.0
6 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | """
2 | Based entirely on Django's own ``setup.py``.
3 | """
4 | import os
5 | import sys
6 | import codecs
7 | import re
8 | from setuptools import setup, find_packages
9 |
10 | PACKAGE_NAME = 'navutils'
11 |
12 | def read(*parts):
13 | file_path = os.path.join(os.path.dirname(__file__), *parts)
14 | return codecs.open(file_path, encoding='utf-8').read()
15 |
16 |
17 | def find_version(*parts):
18 | version_file = read(*parts)
19 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M)
20 | if version_match:
21 | return str(version_match.group(1))
22 | raise RuntimeError("Unable to find version string.")
23 |
24 |
25 | setup(
26 | name='django-navutils',
27 | version=find_version(PACKAGE_NAME, '__version__.py'),
28 | description="A lightweight package for handling menus and breadcrumbs in your django project",
29 | long_description=read('README.rst'),
30 | author='Eliot Berriot',
31 | author_email='contact@eliotberriot.com',
32 | maintainer='Eliot Berriot',
33 | maintainer_email='contact@eliotberriot.com',
34 | url='http://github.com/EliotBerriot/django-navutils',
35 | license='BSD License',
36 | platforms=['any'],
37 | packages=find_packages(),
38 | package_data = {
39 | 'navutils': [
40 | 'templates/navutils/*.html',
41 | ],
42 | },
43 | include_package_data=True,
44 | install_requires=['persisting_theory'],
45 | classifiers=[
46 | 'Development Status :: 4 - Beta',
47 | 'Environment :: Web Environment',
48 | 'Framework :: Django',
49 | 'Intended Audience :: Developers',
50 | 'License :: OSI Approved :: BSD License',
51 | 'Operating System :: OS Independent',
52 | 'Programming Language :: Python',
53 | 'Programming Language :: Python :: 3',
54 | 'Framework :: Django :: 1.7',
55 | 'Framework :: Django :: 1.8',
56 | 'Topic :: Utilities',
57 | ],
58 | )
59 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/agateblue/django-navutils/27b0cc56641ad60dcf6ab080714657bc207d2c3b/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_app/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/agateblue/django-navutils/27b0cc56641ad60dcf6ab080714657bc207d2c3b/tests/test_app/__init__.py
--------------------------------------------------------------------------------
/tests/test_app/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class TestModel(models.Model):
5 |
6 | class Meta:
7 | permissions = (
8 | ('foo', 'Foo'),
9 | ('bar', 'Bar'),
10 | )
11 |
--------------------------------------------------------------------------------
/tests/test_app/templates/test_app/base.html:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/agateblue/django-navutils/27b0cc56641ad60dcf6ab080714657bc207d2c3b/tests/test_app/templates/test_app/base.html
--------------------------------------------------------------------------------
/tests/test_app/templates/test_app/test_node.html:
--------------------------------------------------------------------------------
1 | {% extends 'navutils/node.html' %}
2 |
3 | {% block node_label %}{{ node.label}} {{ foo }}{% endblock %}
4 |
--------------------------------------------------------------------------------
/tests/test_app/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 |
3 | from . import views
4 |
5 | urlpatterns = [
6 | path('', views.Index.as_view(template_name='test_app/base.html'), name='index'),
7 | path('blog', views.BlogMixin.as_view(template_name='test_app/base.html'), name='blog'),
8 | path('blog/category/', views.CategoryMixin.as_view(template_name='test_app/base.html'), name='category'),
9 | ]
10 |
--------------------------------------------------------------------------------
/tests/test_app/views.py:
--------------------------------------------------------------------------------
1 | from django.views.generic import TemplateView
2 |
3 | from navutils import Breadcrumb, BreadcrumbsMixin, MenuMixin
4 |
5 |
6 | def blank():
7 | return
8 |
9 |
10 | class BaseMixin(BreadcrumbsMixin, MenuMixin, TemplateView):
11 |
12 | def get_breadcrumbs(self):
13 | breadcrumbs = super(BaseMixin, self).get_breadcrumbs()
14 | breadcrumbs.append(Breadcrumb('Home', url='/'))
15 | return breadcrumbs
16 |
17 |
18 | class Index(BaseMixin):
19 | current_menu_item = 'test:index'
20 |
21 |
22 | class BlogMixin(BaseMixin):
23 | def get_breadcrumbs(self):
24 | breadcrumbs = super(BlogMixin, self).get_breadcrumbs()
25 | breadcrumbs.append(Breadcrumb('Blog', url='/blog'))
26 | return breadcrumbs
27 |
28 | class CategoryMixin(BlogMixin):
29 |
30 | def get_title(self):
31 | return self.kwargs['slug'].title()
32 |
33 | def get_current_url(self):
34 | return '/blog/category/{0}'.format(self.kwargs['slug'])
35 |
--------------------------------------------------------------------------------
/tests/test_breadcrumbs.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from navutils import Breadcrumb
4 | from navutils.templatetags import navutils_tags
5 |
6 |
7 | class BreadcrumbTest(TestCase):
8 | def test_breadcrumb_reverse(self):
9 | crumb = Breadcrumb(label='Test', pattern_name='index')
10 |
11 | self.assertEqual(crumb.get_url(), '/')
12 |
13 | def test_breadcrumb_url(self):
14 | crumb = Breadcrumb(label='Test', url='http://test.com')
15 |
16 | self.assertEqual(crumb.get_url(), 'http://test.com')
17 |
18 | def test_mixin_pass_context_data(self):
19 | response = self.client.get('/')
20 |
21 | self.assertEqual(response.context['breadcrumbs'][0].get_url(), '/' )
22 | self.assertEqual(response.context['breadcrumbs'][0].label, 'Home')
23 |
24 | def test_mixin_inherit_crumbs(self):
25 | response = self.client.get('/blog')
26 |
27 | self.assertEqual(response.context['breadcrumbs'][0].get_url(), '/' )
28 | self.assertEqual(response.context['breadcrumbs'][0].label, 'Home')
29 |
30 | self.assertEqual(response.context['breadcrumbs'][1].get_url(), '/blog' )
31 | self.assertEqual(response.context['breadcrumbs'][1].label, 'Blog')
32 |
33 | response = self.client.get('/blog/category/test')
34 |
35 | self.assertEqual(response.context['breadcrumbs'][0].get_url(), '/' )
36 | self.assertEqual(response.context['breadcrumbs'][0].label, 'Home')
37 |
38 | self.assertEqual(response.context['breadcrumbs'][1].get_url(), '/blog' )
39 | self.assertEqual(response.context['breadcrumbs'][1].label, 'Blog')
40 |
41 | self.assertEqual(response.context['breadcrumbs'][2].get_url(), '/blog/category/test' )
42 | self.assertEqual(response.context['breadcrumbs'][2].label, 'Test')
43 |
44 |
45 | class RenderBreadcrumbTest(TestCase):
46 |
47 | def test_render_single_crumb(self):
48 | crumb = Breadcrumb(label='Test', pattern_name='index')
49 |
50 | output = navutils_tags.render_crumb({}, crumb)
51 | self.assertHTMLEqual(
52 | output,
53 | 'Test ')
54 |
55 | def test_render_breadcrumbs(self):
56 | crumbs = []
57 | crumbs.append(Breadcrumb(label='Test1', pattern_name='index'))
58 | crumbs.append(Breadcrumb(label='Test2', url='http://test.com'))
59 |
60 | output = navutils_tags.render_breadcrumbs({}, crumbs)
61 | self.assertHTMLEqual(
62 | output,
63 | """
64 |
68 | """)
69 |
--------------------------------------------------------------------------------
/tests/test_menu.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.contrib.auth import get_user_model
3 | from django.contrib.auth.models import AnonymousUser, Permission
4 | from django.test.client import RequestFactory
5 |
6 | from navutils import menu
7 | from navutils.templatetags import navutils_tags
8 |
9 | User = get_user_model()
10 |
11 |
12 | class BaseTestCase(TestCase):
13 | def setUp(self):
14 | self.factory = RequestFactory()
15 |
16 | self.user = User(username='user')
17 | self.user.set_password('test')
18 | self.user.save()
19 | self.anonymous_user = AnonymousUser()
20 |
21 | self.admin = User(username='admin', is_superuser=True)
22 | self.admin.save()
23 |
24 | self.staff_member = User(username='staff', is_staff=True)
25 | self.staff_member.save()
26 |
27 |
28 |
29 | class MenuTest(BaseTestCase):
30 | def test_menu_can_register_nodes(self):
31 | main_menu = menu.Menu('main')
32 | node = menu.Node('test', 'Test', url='http://test.com')
33 | main_menu.register(node)
34 |
35 | self.assertEqual(main_menu['test'], node)
36 |
37 |
38 | class NodeTest(BaseTestCase):
39 |
40 | def test_menu_node_allows_arbitrary_url(self):
41 | node = menu.Node('test', 'Test', url='http://test.com')
42 |
43 | self.assertEqual(node.get_url(), 'http://test.com')
44 |
45 | def test_menu_node_allows_django_pattern_name(self):
46 | node = menu.Node('test', 'Test', pattern_name='index')
47 |
48 | self.assertEqual(node.get_url(), '/')
49 |
50 | def test_menu_node_detects_misconfiguration(self):
51 | with self.assertRaises(ValueError):
52 | menu.Node('test', 'Test')
53 | with self.assertRaises(ValueError):
54 | menu.Node('test', 'Test', pattern_name='index', url='/')
55 | with self.assertRaises(ValueError):
56 | menu.Node('test', 'Test', divider=True, pattern_name='index')
57 |
58 | def test_menu_node_divider(self):
59 | node = menu.Node('test', 'Test', divider=True)
60 | self.assertTrue(node.is_divider)
61 |
62 | def test_menu_node_allows_django_pattern_name_with_kwargs(self):
63 | node = menu.Node('test', 'Test', pattern_name='category', reverse_kwargs=['slug'])
64 |
65 | self.assertEqual(node.get_url(slug='test'), '/blog/category/test')
66 |
67 | def test_children_keep_reference_to_parent(self):
68 | child = menu.Node('c', 'Child', url='http://test.com/child')
69 | parent = menu.Node('test', 'Test', url='http://test.com', children=[child])
70 |
71 | self.assertEqual(child.parent, parent)
72 |
73 | def test_node_depth(self):
74 | subchild = menu.Node('sc', 'SubChild', url='http://test.com/subchild')
75 | child = menu.Node('c', 'Child', url='http://test.com/child', children=[subchild])
76 | parent = menu.Node('test', 'Test', url='http://test.com', children=[child])
77 |
78 | self.assertEqual(parent.depth, 0)
79 | self.assertEqual(child.depth, 1)
80 | self.assertEqual(subchild.depth, 2)
81 |
82 |
83 | def test_menu_node_sort_children_by_weight(self):
84 | child1 = menu.Node('c1', 'Child1', weight=3, url='http://test.com/child1')
85 | child2 = menu.Node('c2', 'Child2', weight=1, url='http://test.com/child2')
86 | child3 = menu.Node('c3', 'Child3', weight=2, url='http://test.com/child3')
87 |
88 | children = [child1, child2, child3]
89 | parent = menu.Node(
90 | 'test',
91 | 'Test',
92 | url='http://test.com',
93 | children=children
94 | )
95 |
96 | self.assertEqual(parent.children, [child1, child3, child2])
97 | # we add another child, order should be updated
98 | child4 = menu.Node('c4', 'Child4', weight=999, url='http://test.com/child4')
99 | parent.add(child4)
100 |
101 | self.assertEqual(parent.children, [child4, child1, child3, child2])
102 |
103 | def test_children_accept_a_callable(self):
104 |
105 | def generate_children():
106 | return [menu.Node(i, i, url='#') for i in range(5)]
107 |
108 | parent = menu.Node(
109 | 'test',
110 | 'Test',
111 | url='http://test.com',
112 | children=generate_children
113 | )
114 | for i in range(5):
115 | self.assertEqual(parent.children[i].id, i)
116 |
117 | def test_menu_node_is_viewable_by_anobody(self):
118 | node = menu.Node('test', 'Test', url='http://test.com')
119 |
120 | self.assertTrue(node.is_viewable_by(self.user))
121 |
122 |
123 | def test_node_id_is_built_from_menu_and_parent(self):
124 | subchild = menu.Node('sc', 'SubChild', url='http://test.com/subchild')
125 | child = menu.Node('c', 'Child', url='http://test.com/child', children=[subchild])
126 | parent = menu.Node('test', 'Test', url='http://test.com', children=[child])
127 |
128 |
129 | self.assertEqual(parent.id, 'test')
130 | self.assertEqual(child.id, 'test:c')
131 | self.assertEqual(subchild.id, 'test:c:sc')
132 |
133 | class AnonymousNodeTest(BaseTestCase):
134 |
135 | def test_is_viewable_by_anonymous_user(self):
136 | node = menu.AnonymousNode('test', 'Test', url='http://test.com')
137 |
138 | self.assertTrue(node.is_viewable_by(self.anonymous_user))
139 | self.assertFalse(node.is_viewable_by(self.user))
140 |
141 |
142 | class AuthenticatedNodeTest(BaseTestCase):
143 |
144 | def test_is_viewable_by_authenticated_user(self):
145 | node = menu.AuthenticatedNode('test', 'Test', url='http://test.com')
146 |
147 | self.assertFalse(node.is_viewable_by(self.anonymous_user))
148 | self.assertTrue(node.is_viewable_by(self.user))
149 |
150 |
151 | class StaffNodeTest(BaseTestCase):
152 |
153 | def test_is_viewable_by_staff_members_or_admin(self):
154 | node = menu.StaffNode('test', 'Test', url='http://test.com')
155 |
156 | self.assertTrue(node.is_viewable_by(self.staff_member))
157 | self.assertTrue(node.is_viewable_by(self.admin))
158 | self.assertFalse(node.is_viewable_by(self.user))
159 | self.assertFalse(node.is_viewable_by(self.anonymous_user))
160 |
161 |
162 | class PermissionNodeTest(BaseTestCase):
163 |
164 | def test_is_viewable_by_user_with_required_permission(self):
165 |
166 | node = menu.PermissionNode('test', 'Test', url='http://test.com', permission='test_app.foo')
167 |
168 | self.assertTrue(node.is_viewable_by(self.admin))
169 | self.assertFalse(node.is_viewable_by(self.anonymous_user))
170 | self.assertFalse(node.is_viewable_by(self.user))
171 |
172 | permission = Permission.objects.get(codename='foo')
173 | self.user.user_permissions.add(permission)
174 | self.user = User.objects.get(pk=self.user.pk)
175 |
176 | self.assertTrue(node.is_viewable_by(self.user))
177 |
178 |
179 | class AllPermissionsNodeTest(BaseTestCase):
180 |
181 | def test_is_viewable_by_user_with_required_permissions(self):
182 |
183 | node = menu.AllPermissionsNode('test', 'Test', url='http://test.com', permissions=['test_app.foo', 'test_app.bar'])
184 |
185 | self.assertTrue(node.is_viewable_by(self.admin))
186 | self.assertFalse(node.is_viewable_by(self.anonymous_user))
187 | self.assertFalse(node.is_viewable_by(self.user))
188 |
189 | permission = Permission.objects.get(codename='foo')
190 | self.user.user_permissions.add(permission)
191 | self.user = User.objects.get(pk=self.user.pk)
192 |
193 | self.assertFalse(node.is_viewable_by(self.user))
194 |
195 | permission = Permission.objects.get(codename='bar')
196 | self.user.user_permissions.add(permission)
197 | self.user = User.objects.get(pk=self.user.pk)
198 |
199 | self.assertTrue(node.is_viewable_by(self.user))
200 |
201 |
202 | class AnyPermissionsNodeTest(BaseTestCase):
203 |
204 | def test_is_viewable_by_user_with_any_required_permissions(self):
205 |
206 | node = menu.AnyPermissionsNode('test', 'Test', url='http://test.com', permissions=['test_app.foo', 'test_app.bar'])
207 |
208 | self.assertTrue(node.is_viewable_by(self.admin))
209 | self.assertFalse(node.is_viewable_by(self.anonymous_user))
210 | self.assertFalse(node.is_viewable_by(self.user))
211 |
212 | permission = Permission.objects.get(codename='foo')
213 | self.user.user_permissions.add(permission)
214 | self.user = User.objects.get(pk=self.user.pk)
215 |
216 | self.assertTrue(node.is_viewable_by(self.user))
217 |
218 | self.user.user_permissions.remove(permission)
219 |
220 | permission = Permission.objects.get(codename='bar')
221 | self.user.user_permissions.add(permission)
222 | self.user = User.objects.get(pk=self.user.pk)
223 |
224 | self.assertTrue(node.is_viewable_by(self.user))
225 |
226 |
227 | class PassTestNode(BaseTestCase):
228 |
229 | def test_is_viewable_by_user_with_any_required_permissions(self):
230 | test = lambda user, context: 'chuck' in user.username
231 |
232 | node = menu.PassTestNode('test', 'Test', url='http://test.com', test=test)
233 |
234 | self.assertFalse(node.is_viewable_by(self.admin))
235 | self.assertFalse(node.is_viewable_by(self.user))
236 |
237 | self.user.username = 'chucknorris'
238 |
239 | self.assertTrue(node.is_viewable_by(self.user))
240 |
241 |
242 |
243 | class RenderNodeTest(BaseTestCase):
244 |
245 | def test_render_node_template_tag(self):
246 | node = menu.Node('test', 'Test', url='http://test.com')
247 |
248 | output = navutils_tags.render_node({}, node=node, user=self.user)
249 | self.assertHTMLEqual(
250 | output,
251 | '')
252 |
253 | def test_render_node_template_tag_with_divider(self):
254 | node = menu.Node('test', '', divider=True, css_class='divider')
255 |
256 | output = navutils_tags.render_node({}, node=node, user=self.user)
257 | self.assertHTMLEqual(
258 | output,
259 | '')
260 |
261 | def test_render_node_template_tag_with_header(self):
262 | node = menu.Node('test', 'Heading', divider=True, css_class='header')
263 |
264 | output = navutils_tags.render_node({}, node=node, user=self.user)
265 | self.assertHTMLEqual(
266 | output,
267 | '')
268 |
269 | def test_render_node_template_tag_with_embedded_template(self):
270 | node = menu.Node('test', '{{ 1|add:"2" }}', url='http://test.com')
271 |
272 | output = navutils_tags.render_node({}, node=node, user=self.user)
273 | self.assertHTMLEqual(
274 | output,
275 | '')
276 |
277 | def test_render_node_template_tag_with_embedded_template_and_context(self):
278 | node = menu.Node('test', '{{ foo }}', url='http://test.com',
279 | context={'foo': 'bar'})
280 |
281 | output = navutils_tags.render_node({}, node=node, user=self.user)
282 | self.assertHTMLEqual(
283 | output,
284 | '')
285 |
286 | def test_render_node_template_tag_with_extra_context(self):
287 | node = menu.Node('test', 'Test', url='http://test.com', template='test_app/test_node.html',
288 | context={'foo': 'bar'})
289 |
290 | output = navutils_tags.render_node({}, node=node, user=self.user)
291 | self.assertHTMLEqual(
292 | output,
293 | '')
294 |
295 |
296 | def test_render_node_template_tagwith_current(self):
297 | node = menu.Node('test', 'Test', url='http://test.com')
298 |
299 | output = navutils_tags.render_node({'current_menu_item':'test'}, node=node, user=self.user)
300 | self.assertHTMLEqual(
301 | output,
302 | '')
303 |
304 |
305 | def test_render_node_template_tag_with_link_attrs(self):
306 | attrs = {'target': '_blank', 'title': 'Click me !'}
307 | node = menu.Node('test', 'Test', url='http://test.com', link_attrs=attrs)
308 |
309 | output = navutils_tags.render_node({}, node=node, user=self.user)
310 | self.assertHTMLEqual(
311 | output,
312 | """""")
315 |
316 | def test_render_node_template_tag_with_node_attrs(self):
317 | attrs = {'id': 'important'}
318 | node = menu.Node('test', 'Test', url='http://test.com', attrs=attrs)
319 |
320 | output = navutils_tags.render_node({}, node=node, user=self.user)
321 | self.assertHTMLEqual(
322 | output,
323 | """""")
326 |
327 | def test_render_node_template_tag_with_children(self):
328 | child1 = menu.Node('c1', 'c1', url='c1')
329 | child2 = menu.Node('c2', 'c2', url='c2')
330 | child3 = menu.Node('c3', 'c3', url='c3')
331 |
332 | node = menu.Node(
333 | 'test',
334 | 'Test',
335 | url='http://test.com',
336 | children=[
337 | child1,
338 | child2,
339 | child3,
340 | ]
341 | )
342 |
343 | output = navutils_tags.render_node({}, node=node, user=self.user)
344 |
345 | self.assertHTMLEqual(
346 | output,
347 | """
348 |
356 | """)
357 |
358 | def test_render_node_template_tag_with_children_and_current(self):
359 | child1 = menu.Node('c1', 'c1', url='c1')
360 | child2 = menu.Node('c2', 'c2', url='c2')
361 | child3 = menu.Node('c3', 'c3', url='c3')
362 |
363 | node = menu.Node(
364 | 'test',
365 | 'Test',
366 | url='http://test.com',
367 | children=[
368 | child1,
369 | child2,
370 | child3,
371 | ]
372 | )
373 |
374 | output = navutils_tags.render_node({'current_menu_item':'test:c3'}, node=node, user=self.user)
375 |
376 | self.assertHTMLEqual(
377 | output,
378 | """
379 |
387 | """)
388 |
389 | def test_render_node_template_tag_with_children_and_depth(self):
390 | subchild = menu.Node('s1', 's1', url='s1')
391 | child1 = menu.Node('c1', 'c1', url='c1', children=[subchild])
392 | child2 = menu.Node('c2', 'c2', url='c2')
393 | child3 = menu.Node('c3', 'c3', url='c3')
394 |
395 | node = menu.Node(
396 | 'test',
397 | 'Test',
398 | url='http://test.com',
399 | children=[
400 | child1,
401 | child2,
402 | child3,
403 | ]
404 | )
405 |
406 | output = navutils_tags.render_node({}, node=node, user=self.user, max_depth=1)
407 |
408 | self.assertHTMLEqual(
409 | output,
410 | """
411 |
419 | """)
420 |
421 | output = navutils_tags.render_node({}, node=node, user=self.user, max_depth=0)
422 |
423 | self.assertHTMLEqual(
424 | output,
425 | """
426 |
429 | """)
430 |
431 |
432 | class MenuMixinTest(BaseTestCase):
433 |
434 | def test_set_current_menu_item_in_context(self):
435 | response = self.client.get('/')
436 | self.assertEqual(response.context['current_menu_item'], 'test:index')
437 |
438 |
439 | class RenderMenuTest(BaseTestCase):
440 |
441 | def test_template_tag(self):
442 | main_menu = menu.Menu('main')
443 | node = menu.Node('test', 'Test', url='http://test.com')
444 | main_menu.register(node)
445 |
446 | output = navutils_tags.render_menu({}, menu=main_menu, user=self.user)
447 |
448 | self.assertHTMLEqual(
449 | output,
450 | """
451 |
454 | """)
455 |
456 | def test_context_available_in_template(self):
457 | main_menu = menu.Menu('main')
458 | node = menu.Node('context', 'Context', url='http://test-context.com',
459 | template='test_app/test_node.html')
460 | main_menu.register(node)
461 |
462 | output = navutils_tags.render_menu({'foo': 'bar'}, menu=main_menu,
463 | user=self.user)
464 |
465 | self.assertHTMLEqual(
466 | output,
467 | """
468 |
471 | """
472 | )
473 |
--------------------------------------------------------------------------------
/tests/test_mixins.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.views.generic import TemplateView
3 | from django.test.client import RequestFactory
4 |
5 | from navutils import mixins
6 | from navutils.templatetags import navutils_tags
7 |
8 |
9 | class MixinsTest(TestCase):
10 |
11 | def setUp(self):
12 | self.factory = RequestFactory()
13 |
14 | def test_title_mixin(self):
15 |
16 | class TitleView(mixins.TitleMixin, TemplateView):
17 | template_name = 'test_app/test.html'
18 | title = 'yolo'
19 |
20 | view = TitleView.as_view()
21 | response = view(self.factory.get('/'))
22 |
23 | self.assertEqual(response.context_data['title'], 'yolo')
24 |
25 | def test_dynamic_title_mixin(self):
26 |
27 | class TitleView(mixins.TitleMixin, TemplateView):
28 | template_name = 'test_app/test.html'
29 | def get_title(self):
30 | return self.__class__.__name__
31 |
32 | view = TitleView.as_view()
33 | response = view(self.factory.get('/'))
34 |
35 | self.assertEqual(response.context_data['title'], 'TitleView')
36 |
37 | def test_dynamic_description_mixin(self):
38 |
39 | class DescriptionView(mixins.DescriptionMixin, TemplateView):
40 | template_name = 'test_app/test.html'
41 | def get_description(self):
42 | return self.__class__.__name__
43 |
44 | view = DescriptionView.as_view()
45 | response = view(self.factory.get('/'))
46 |
47 | self.assertEqual(response.context_data['description'], 'DescriptionView')
48 |
49 | def test_description_mixin(self):
50 |
51 | class DescriptionView(mixins.DescriptionMixin, TemplateView):
52 | template_name = 'test_app/test.html'
53 | description = 'my meta description'
54 |
55 | view = DescriptionView.as_view()
56 | response = view(self.factory.get('/'))
57 |
58 | self.assertEqual(response.context_data['description'], 'my meta description')
59 |
--------------------------------------------------------------------------------
/tests/test_render_nested.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from django.template import Context
3 |
4 | from navutils.templatetags import navutils_tags
5 |
6 |
7 | class RenderNestedTest(TestCase):
8 |
9 | def test_render_nested_block(self):
10 | block = '{% filter lower %}This Will All Be Lower{% endfilter %}'
11 |
12 | output = navutils_tags.render_nested(Context({}), block)
13 | self.assertEqual(
14 | output,
15 | 'this will all be lower')
16 |
17 | def test_render_nested_variable(self):
18 | block = '{{ value|join:" // " }}'
19 |
20 | output = navutils_tags.render_nested(Context({'value': [1, 2, 3]}), block)
21 | self.assertEqual(
22 | output,
23 | '1 // 2 // 3')
24 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [tox]
2 | skip_missing_interpreters=true
3 | envlist =
4 | {py37,}-django-22
5 | {py37,py38,py39}-django-32
6 | {py38,py39}-django-master
7 |
8 | [testenv]
9 | setenv =
10 | PYTHONPATH = {toxinidir}:{toxinidir}/navutils
11 | commands = py.test {posargs}
12 | deps =
13 | django-22: Django>=2.2,<2.3
14 | django-32: Django>=3.2,<3.3
15 | django-master: https://github.com/django/django/archive/master.tar.gz
16 | persisting-theory
17 | -r{toxinidir}/requirements/test.txt
18 | basepython =
19 | py39: python3.9
20 | py38: python3.8
21 | py37: python3.7
22 |
--------------------------------------------------------------------------------