Skip to content

Commit

Permalink
Merge pull request #77 from level12/58-nav-features
Browse files Browse the repository at this point in the history
Nav generation: add additional features
  • Loading branch information
guruofgentoo committed Sep 26, 2019
2 parents ab47362 + 145f5e8 commit 4473571
Show file tree
Hide file tree
Showing 12 changed files with 207 additions and 21 deletions.
24 changes: 22 additions & 2 deletions keg_auth/libs/navigation.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import inspect
import sys

from blazeutils.strings import simplify_string
import flask
import flask_login
import six

from keg_auth.extensions import lazy_gettext as _
from keg_auth.model.utils import has_permissions

try:
from speaklater import is_lazy_string
except ImportError:
is_lazy_string = lambda value: False # noqa: E731


def get_defining_class(func):
if inspect.isclass(func):
Expand Down Expand Up @@ -124,13 +130,15 @@ class NavItemType(object):
STEM = 0
LEAF = 1

def __init__(self, *args):
def __init__(self, *args, nav_group=None, icon_class=None):
self.label = None
if len(args) and isinstance(args[0], six.string_types):
if len(args) and (isinstance(args[0], six.string_types) or is_lazy_string(args[0])):
self.label = args[0]
args = args[1:]
self.route = None
self.sub_nodes = None
self.nav_group = nav_group
self.icon_class = icon_class

# cache permission-related items
self._is_permitted = {}
Expand All @@ -148,6 +156,8 @@ def __init__(self, *args):

if len(args):
self.sub_nodes = args
if not self.nav_group:
self.nav_group = simplify_string(self.label or '__root__')

def clear_authorization(self, session_key):
self._is_permitted.pop(session_key, None)
Expand Down Expand Up @@ -185,3 +195,13 @@ def permitted_sub_nodes(self):
]

return self._permitted_sub_nodes.get(session_key)

@property
def has_current_route(self):
if self.route:
return self.route.route_string == flask.request.endpoint
else:
for node in self.permitted_sub_nodes:
if node.has_current_route:
return True
return False
53 changes: 38 additions & 15 deletions keg_auth/templates/keg_auth/navigation.html
Original file line number Diff line number Diff line change
@@ -1,21 +1,44 @@
{% macro render_node(nav_node) -%}
{% macro render_link_text(node) %}
{% if node.icon_class %}<i class="{{ node.icon_class }}"></i>{% endif %}{{ node.label | trim }}
{% endmacro %}

{% macro render_node(node, expand_to_current) -%}
{# render a node (and its children, if appropriate) #}
{% set NODE_LEAF = 1 %}
<li>
{% if nav_node.node_type == NODE_LEAF %}
<a href="{{ nav_node.route.url }}">{{ nav_node.label }}</a>
{% else %}
<a class="menu-header">{{ nav_node.label }}</a>
<ul>
{% for sub_node in nav_node.permitted_sub_nodes %}
{{ render_node(sub_node) }}
{% endfor %}
</ul>
{% endif %}

{% if node.node_type == NODE_LEAF %}
<li{% if node.has_current_route %} class="nav-current"{% endif %}>
<a href="{{ node.route.url }}">{{ render_link_text(node) }}</a>
</li>
{% elif node.sub_nodes %}
<li>{{ render_group(node, expand_to_current) }}</li>
{% else %}
<li><a class="menu-header">{{ render_link_text(node) }}</a></li>
{% endif %}
{%- endmacro %}

{% macro render_menu(nav_node) -%}
{% for sub_node in nav_node.permitted_sub_nodes %}
{{ render_node(sub_node) }}
{% macro render_group(node, expand_to_current) %}
<a class="menu-header group-header" data-toggle="collapse" href="#navgroup-{{ node.nav_group }}"
{% if expand_to_current and node.has_current_route %}aria-expanded="true"{% endif %}>
{{ render_link_text(node) }}
<b class="caret"></b>
</a>
<div class="collapse {% if expand_to_current and node.has_current_route %}in{% endif %}"
id="navgroup-{{ node.nav_group }}">
<ul>
{% for sub_node in node.permitted_sub_nodes %}
{{ render_node(sub_node, expand_to_current) }}
{% endfor %}
</ul>
</div>
{% endmacro %}

{% macro render_menu(node, expand_to_current=False) -%}
{% for sub_node in node.permitted_sub_nodes %}
{{ render_node(sub_node, expand_to_current) }}
{% endfor %}
{%- endmacro %}

{% if auth_manager is defined %}
{{ render_menu(auth_manager.menus['main']) }}
{% endif %}
74 changes: 74 additions & 0 deletions keg_auth/tests/test_navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import unicode_literals

import sys
from unittest import mock

import flask
import flask_login
Expand Down Expand Up @@ -80,6 +81,72 @@ def test_node_invalid_endpoint(self):
):
NavItem('Foo', NavURL('pink_unicorns')).is_permitted

def test_nav_group_not_assigned(self):
node = NavItem('Foo', NavURL('public.home'))
assert not node.nav_group

def test_nav_group_auto_assigned(self):
node = NavItem(
NavItem(
'This Beard Stays',
NavItem('Foo2', NavURL('public.home')),
),
NavItem(
'Bar',
NavItem('Bar2', NavURL('private.secret1')),
)
)
assert node.sub_nodes[0].nav_group == 'this-beard-stays'

def test_nav_group_manual_preserved(self):
node = NavItem(
NavItem(
'Foo',
NavItem('Foo2', NavURL('public.home')),
nav_group='this-beard-stays'
),
NavItem(
'Bar',
NavItem('Bar2', NavURL('private.secret1')),
),
)
assert node.sub_nodes[0].nav_group == 'this-beard-stays'

def test_current_route_not_exists(self):
node = NavItem('Foo', NavURL('public.home'))

with flask.current_app.test_request_context('/some-random-route'):
assert not node.has_current_route

def test_current_route_not_matched(self):
node = NavItem('Foo', NavURL('public.home'))

with flask.current_app.test_request_context('/secret1'):
assert not node.has_current_route

def test_current_route_matched(self):
node = NavItem('Foo', NavURL('public.home'))

with flask.current_app.test_request_context('/'):
assert node.has_current_route

def test_current_route_matched_nested(self):
node = NavItem(
NavItem(
'Foo',
NavItem('Foo2', NavURL('public.home')),
),
NavItem(
'Bar',
NavItem('Bar2', NavURL('private.secret1')),
)
)

with flask.current_app.test_request_context('/'):
assert node.has_current_route
assert node.sub_nodes[0].has_current_route
assert not node.sub_nodes[1].has_current_route

def test_leaf_no_requirement(self):
node = NavItem('Foo', NavURL('public.home'))

Expand Down Expand Up @@ -313,3 +380,10 @@ def test_per_user_menu_items(self):
permissions=[perm1])
flask_login.login_user(user)
assert node.is_permitted

@mock.patch('keg_auth.libs.navigation.is_lazy_string', return_value=True)
def test_lazy_string_label(self, _):
# Pass a non-string label so is_lazy_string gets called.
label = 12
node = NavItem(label, NavURL('public.home'))
assert node.label == label
31 changes: 31 additions & 0 deletions keg_auth/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,37 @@ def test_rendered_navigation(self):
assert not doc.find('div#navigation a[href="/users"]')
assert not doc.find('div#navigation a[href="/secret-nested"]')

def test_navigation_group(self):
user = ents.User.testing_create(permissions=[self.perm_auth])
client = AuthTestApp(flask.current_app, user=user)
resp = client.get('/')
nav_el = resp.pyquery('#navigation')
assert nav_el('[href="#navgroup-auth"]').attr('aria-expanded') != 'true'
assert len(nav_el('[aria-expanded="true"]')) == 0
assert not nav_el('#navgroup-auth').has_class('in')
assert nav_el('.nav-current').text() == 'Home'

resp = client.get('/users')
nav_el = resp.pyquery('#navigation')
assert nav_el('[href="#navgroup-auth"]').attr('aria-expanded') == 'true'
assert len(nav_el('[aria-expanded="true"]')) == 1
assert nav_el('#navgroup-auth').has_class('in')

with mock.patch.object(flask.current_app, 'template_globals', {'auto_expand_menu': False}):
resp = client.get('/users')
nav_el = resp.pyquery('#navigation')
assert nav_el('[href="#navgroup-auth"]').attr('aria-expanded') != 'true'

def test_navigation_icon(self):
user = ents.User.testing_create(permissions=[self.perm_auth])
client = AuthTestApp(flask.current_app, user=user)
resp = client.get('/')
nav_el = resp.pyquery('#navigation')
assert nav_el('[href="#navgroup-auth"]')('i.fas.fa-bomb')
assert len(nav_el('i.fas.fa-bomb')) == 1
assert '<i class="fas fa-ad"/>User Manage 3' in nav_el.html()
assert len(nav_el('i.fas.fa-ad')) == 1

def test_authenticated_client(self):
user = ents.User.testing_create()
client = AuthTestApp(flask.current_app, user=user)
Expand Down
5 changes: 5 additions & 0 deletions keg_auth_ta/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from flask_bootstrap import Bootstrap
from keg.app import Keg
from keg.db import db
from werkzeug.datastructures import ImmutableDict

from keg_auth_ta.extensions import auth_manager, csrf, mail_ext
from keg_auth_ta.grids import Grid
Expand All @@ -17,6 +18,10 @@ class KegAuthTestApp(Keg):
visit_modules = ['.events']

def on_init_complete(self):
self.template_globals = ImmutableDict(
auto_expand_menu=self.config.get('AUTO_EXPAND_MENU')
)

auth_manager.init_app(self)
csrf.init_app(self)
mail_ext.init_app(self)
Expand Down
2 changes: 2 additions & 0 deletions keg_auth_ta/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class DefaultProfile(object):
SITE_NAME = 'Keg Auth Demo'
SITE_ABBR = 'KA Demo'

AUTO_EXPAND_MENU = True


class TestProfile(object):
# Make tests faster
Expand Down
9 changes: 8 additions & 1 deletion keg_auth_ta/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,15 @@ def init_navigation(app):
NavItem('Home', NavURL('public.home')),
NavItem(
'Sub-Menu',
NavItem('User Manage', NavURL('auth.user:list')),
NavItem('User Manage', NavURL('private.secret2')),
NavItem('Secret View', NavURL('private.secret_nested')),
),
NavItem(
'Menu-Group',
NavItem('User Manage 2', NavURL('auth.user:list')),
NavItem('User Manage 3', NavURL('auth.user:list'), icon_class='fas fa-ad'),
nav_group='auth',
icon_class='fas fa-bomb'
),
)
)
6 changes: 5 additions & 1 deletion keg_auth_ta/templates/base-page.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
{% import "bootstrap/utils.html" as utils %}
{% import "keg_auth/navigation.html" as navigation %}
{% import "keg_auth/navigation.html" as navigation with context %}
{% extends "base.html" %}

{% block content %}
<div id="navigation">
{% if auto_expand_menu %}
{{ navigation.render_menu(auth_manager.menus['main'], expand_to_current=True) }}
{% else %}
{{ navigation.render_menu(auth_manager.menus['main']) }}
{% endif %}
</div>
<div class="container" id="flash-messages">
{%- with messages = get_flashed_messages(with_categories=True) %}
Expand Down
2 changes: 2 additions & 0 deletions keg_auth_ta/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class Home(keg.web.BaseView):
blueprint = public_bp
url = '/'
template_name = 'home.html'
nav_group = 'auth'
auto_assign = ['nav_group']

def get(self):
pass
Expand Down
17 changes: 17 additions & 0 deletions readme.rst
Original file line number Diff line number Diff line change
Expand Up @@ -197,13 +197,30 @@ Usage

- Keg-Auth provides navigation helpers to set up a menu tree, for which nodes on the tree are
restricted according to the authentication/authorization requirements of the target endpoint

- Note: requirements are any class-level permission requirements. If authorization is defined
by an instance-level ``check_auth`` method, that will not be used by the navigation helpers

- Usage involves setting up a menu structure with NavItem/NavURL objects. Note that permissions on
a route may be overridden for navigation purposes
- Menus may be tracked on the auth manager, which will reset their cached access on
login/logout
- ``keg_auth/navigation.html`` template has a helper ``render_menu`` to render a given menu as a ul

- ``{% import "keg_auth/navigation.html" as navigation %}``
- ``render_menu(auth_manager.menus['main'])``
- ``render_menu(auth_manager.menus['main'], expand_to_current=True)``

- Automatically expand/collapse menu groups for the currently-viewed item. Useful for vertical menus.

- Collapsible groups can be added to navigation menus by nesting NavItems in the menu. The group item
will get a ``nav_group`` attribute, which can be referred to in CSS.

- ``NavItem('Auth Menu', NavItem(...))`` will have a ``nav_group`` of ``#navgroup-auth-menu``
- ``NavItem('Auth Menu', NavItem(...), nav_group='foo')`` will have a ``nav_group`` of ``#navgroup-foo``

- NavItems can specify an icon to display in the menu item by passing an ``icon_class`` string to the
NavItem constructor. e.g., ``NavItem('Title', NavURL(...), icon_class='fas fa-shopping-cart')``.
- Example:

.. code-block:: python
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
version=version_globals['VERSION'],
description='Authentication plugin for Keg',
long_description='\n\n'.join((README, CHANGELOG)),
long_description_content_type='text/x-rst',
author='Randy Syring',
author_email='randy.syring@level12.io',
url='https://github.com/level12/keg-auth',
Expand Down
4 changes: 2 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ deps =
readme_renderer
pytest
commands =
pip install .[test]
i18n: pip install .[i18n]
pip install --quiet .[test]
i18n: pip install --quiet .[i18n]
py.test \
--disable-pytest-warnings \
--disable-warnings \
Expand Down

0 comments on commit 4473571

Please sign in to comment.