Source code for hawat.menu

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# -------------------------------------------------------------------------------
# This file is part of Mentat system (https://mentat.cesnet.cz/).
#
# Copyright (C) since 2011 CESNET, z.s.p.o (http://www.ces.net/)
# Use of this source is governed by the MIT license, see LICENSE file.
# -------------------------------------------------------------------------------


"""
This module contains useful menu representation *Hawat* application.
"""

__author__ = "Jan Mach <jan.mach@cesnet.cz>"
__credits__ = "Pavel Kácha <pavel.kacha@cesnet.cz>, Andrea Kropáčová <andrea.kropacova@cesnet.cz>"

import re
import collections

import werkzeug.routing
import flask
import flask_login
import flask_principal

import hawat.acl

CRE_STRIP_QUESTION = re.compile(r'\?$')

ENTRY_SUBMENU = 'submenu'
ENTRY_SUBMENUDB = 'submenudb'
ENTRY_VIEW = 'view'
ENTRY_ENDPOINT = 'endpoint'
ENTRY_LINK = 'link'
ENTRY_TEST = 'test'


def _url_segments(path):
    parts = path.split('/')[1:]
    if parts and parts[-1] == '':
        parts.pop()
    return parts


def _is_active(this_url, request):
    request_path = request.script_root + request.path
    request_path_full = request.script_root + request.full_path
    # For some reason in certain cases the '?' is appended to the end of request
    # path event in case there are no additional parameters. Get rid of that.
    request_path_full = CRE_STRIP_QUESTION.sub('', request_path_full)
    if len(this_url) > 1:
        segments_url = _url_segments(this_url)
        segments_request = _url_segments(request_path)
        matching_segments = segments_request == segments_url
    else:
        matching_segments = False
    matching_completpath = request_path_full == this_url
    return matching_segments or matching_completpath


def _filter_menu_entries(entries, **kwargs):
    """
    *Helper function*. Filter given list of menu entries for current user. During
    the filtering following operations will be performed:

    * Remove all entries accessible only for authenticated users, when the current
      user is not authenticated.
    * Remove all entries for which the current user has not sufficient permissions.
    * Remove all empty submenu entries.

    :param collections.OrderedDict entries: List of menu entries.
    :param dict kwargs: Optional menu entry parameters.
    :return: Filtered list of menu entries.
    :rtype: collections.OrderedDict
    """
    result = collections.OrderedDict()
    for entry_id, entry in entries.items():
        # print("Processing menu entry '{}'.".format(entry_id))

        # Filter out entries protected with authentication.
        if entry.authentication:
            if not flask_login.current_user.is_authenticated:
                # print("Hiding menu entry '{}', accessible only to authenticated users.".format(entry_id))
                continue

        # Filter out entries protected with authorization.
        if entry.authorization:
            hideflag = False
            for authspec in entry.authorization:
                # When accessing static files, there is no identity in g so the can() function would raise an exception.
                if 'identity' not in flask.g:
                    hideflag = True
                    break
                # Authorization rules may be specified as instances of flask_principal.Permission.
                if isinstance(authspec, flask_principal.Permission):
                    if not authspec.can():
                        # print("Hiding menu entry '{}', accessible only to '{}'.".format(entry_id, str(authspec)))
                        hideflag = True
                # Authorization rules may be specified as indices to hawat.acl permission dictionary.
                else:
                    if not hawat.acl.PERMISSIONS[authspec].can():
                        # print("Hiding menu entry '{}', accessible only to '{}'.".format(entry_id, str(authspec)))
                        hideflag = True
            if hideflag:
                continue

        if entry.type == ENTRY_SUBMENU:
            # Filter out empty submenus.
            if not _filter_menu_entries(entry._entries, **kwargs):  # pylint: disable=locally-disabled,protected-access
                # print("Hiding menu entry '{}', empty submenu.".format(entry_id))
                continue

        if entry.type == ENTRY_VIEW:
            # Check item action authorization callback, if exists.
            if hasattr(entry.view, 'authorize_item_action'):
                params = entry._pick_params(kwargs)  # pylint: disable=locally-disabled,protected-access
                if not entry.view.authorize_item_action(**params):
                    # print("Hiding menu entry '{}', inaccessible item action for item '{}'.".format(entry_id, str(item)))
                    continue

            # Check item change validation callback, if exists.
            if hasattr(entry.view, 'validate_item_change'):
                params = entry._pick_params(kwargs)  # pylint: disable=locally-disabled,protected-access
                if not entry.view.validate_item_change(**params):
                    # print("Hiding menu entry '{}', invalid item change for item '{}'.".format(entry_id, str(item)))
                    continue

        result[entry_id] = entry

    return result


def _get_menu_entries(entries, **kwargs):
    """
    *Helper function*. Return filtered and sorted menu entries for current user.

    :param collections.OrderedDict entries: List of menu entries.
    :param item: Optional item for which the menu should be parametrized.
    :return: Filtered list of menu entries.
    :rtype: collections.OrderedDict
    """
    return sorted(
        list(
            _filter_menu_entries(entries, **kwargs).values()
        ),
        key=lambda x: x.position
    )


# -------------------------------------------------------------------------------








[docs]class DbSubmenuEntry(SubmenuEntry): """ Class for entries representing whole submenu trees whose contents are fetched on demand from database. """ def __init__(self, ident, **kwargs): super().__init__(ident, **kwargs) self._entry_fetcher = kwargs['entry_fetcher'] self._entry_builder = kwargs['entry_builder'] @property def _entries(self): entries = collections.OrderedDict() items = self._entry_fetcher() if items: for i in items: entry_id = '{}'.format(str(i)) entries[entry_id] = self._entry_builder(entry_id, i) return entries
[docs] def add_entry(self, ident, subentry): raise RuntimeError( "Unable to append entry '{}' to '{}' DB submenu entry.".format( repr(subentry), self.ident ) )
[docs]class ViewEntry(MenuEntry): """ Class representing menu entries pointing to application views. """ def __init__(self, ident, **kwargs): super().__init__(ident, **kwargs) self.type = ENTRY_VIEW self.view = kwargs['view'] self._url = kwargs.get('url', None) @property def endpoint(self): """ Property containing routing endpoint for current entry. :return: Routing endpoint for current menu entry. :rtype: str """ return self.view.get_view_endpoint() @property def authentication(self): """ Property containing authentication information for current entry. :return: Authentication information for current menu entry. :rtype: str """ return self.view.authentication @property def authorization(self): """ Property containing authorization information for current entry. :return: Authorization information for current menu entry. :rtype: str """ return self.view.authorization
[docs] def get_title(self, **kwargs): params = self._pick_params(kwargs) if not self.hidetitle: value = self._title or self.view.get_menu_title(**params) or self.view.get_view_title(**params) if value: try: return value(**params) except TypeError: return value return None
[docs] def get_icon(self, **kwargs): params = self._pick_params(kwargs) if not self.hideicon: value = self._icon or self.view.get_view_icon() if value: try: return value(**params) except TypeError: return value return 'missing-icon'
[docs] def get_legend(self, **kwargs): params = self._pick_params(kwargs) if not self.hidelegend: value = self._legend or self.view.get_menu_legend(**params) if value: try: return value(**params) except TypeError: return value return None
[docs] def get_url(self, **kwargs): """ Return URL for current menu entry. :param dict kwargs: Optional menu entry parameters. :return: URL for current menu entry. :rtype: str """ try: params = self._pick_params(kwargs) value = self._url or self.view.get_view_url(**params) if value: try: return value(**params) except TypeError: return value return flask.url_for(self.endpoint) except werkzeug.routing.BuildError: print("ERROR: Unable to build URL for {}:{}".format(self.ident, str(self.view))) raise
[docs] def get_entries(self, **kwargs): return []
[docs] def add_entry(self, ident, subentry): raise RuntimeError( "Unable to append submenu to '{}' view menu entry.".format( self.ident ) )
[docs] def is_active(self, request, **kwargs): # print("Checking if view menu entry '{}' is active.".format(self.ident)) params = self._pick_params(kwargs) return _is_active( self.get_url(**params), request )
[docs]class EndpointEntry(ViewEntry): """ Class representing menu entries pointing to application routing endpoints. """ def __init__(self, ident, endpoint, **kwargs): kwargs['view'] = flask.current_app.get_endpoint_class(endpoint) super().__init__(ident, **kwargs)
[docs]class LinkEntry(MenuEntry): """ Class representing menu entries pointing to application views. """ def __init__(self, ident, **kwargs): super().__init__(ident, **kwargs) self.type = ENTRY_LINK self.authentication = kwargs.get('authentication', False) self.authorization = kwargs.get('authorization', []) self._url = kwargs.get('url')
[docs] def get_url(self, **kwargs): """ Return URL for current menu entry. :param dict kwargs: Optional menu entry parameters. :return: URL for current menu entry. :rtype: str """ params = self._pick_params(kwargs) try: return self._url(**params) except TypeError: return self._url
[docs] def get_entries(self, **kwargs): return []
[docs] def add_entry(self, ident, subentry): raise RuntimeError( "Unable to append submenu to '{}' link menu entry.".format( self.ident ) )
[docs] def is_active(self, request, **kwargs): # print("Checking if link menu entry '{}' is active.".format(self.ident)) params = self._pick_params(kwargs) return _is_active( self.get_url(**params), request )
[docs]class TestEntry(MenuEntry): """ Class for menu entries for testing and demonstration purposes. """ def __init__(self, ident, **kwargs): super().__init__(ident, **kwargs) self.type = ENTRY_TEST self.authentication = kwargs.get('authentication', False) self.authorization = kwargs.get('authorization', [])
[docs] def get_entries(self, **kwargs): return []
[docs] def add_entry(self, ident, subentry): raise RuntimeError( "Unable to append submenu to '{}' test menu entry.".format( self.ident ) )
[docs] def is_active(self, request, **kwargs): raise RuntimeError( "This method makes no sense for test menu entries." )
# ------------------------------------------------------------------------------- if __name__ == '__main__': MENU = Menu() MENU.add_entry('test', 'test0', position=10) MENU.add_entry('test', 'test1', position=10) MENU.add_entry('test', 'test2', position=20) MENU.add_entry('test', 'test3', position=40) MENU.add_entry('test', 'test4', position=30) MENU.add_entry('submenu', 'sub1', position=50) MENU.add_entry('submenu', 'sub2', position=60) MENU.add_entry('test', 'sub1.test1', position=10) MENU.add_entry('test', 'sub1.test2', position=20) MENU.add_entry('test', 'sub1.test3', position=40) MENU.add_entry('test', 'sub1.test4', position=30) MENU.add_entry('test', 'sub2.test1', position=10) MENU.add_entry('test', 'sub2.test2', position=20) MENU.add_entry('test', 'sub2.test3', position=40) MENU.add_entry('test', 'sub2.test4', position=30) import pprint pprint.pprint(MENU.get_entries()) pprint.pprint(MENU.__class__)