├── .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 | 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 | 13 | {% endif %} 14 | -------------------------------------------------------------------------------- /navutils/templates/navutils/crumb.html: -------------------------------------------------------------------------------- 1 | {% load navutils_tags %} 2 | 3 |
  • 4 | 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 | '
  • ') 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 | --------------------------------------------------------------------------------