diff options
| -rw-r--r-- | fietsboek/__init__.py | 16 | ||||
| -rw-r--r-- | fietsboek/pages.py | 208 | ||||
| -rw-r--r-- | fietsboek/routes.py | 2 | ||||
| -rw-r--r-- | fietsboek/templates/layout.jinja2 | 10 | ||||
| -rw-r--r-- | fietsboek/templates/static-page.jinja2 | 6 | ||||
| -rw-r--r-- | fietsboek/views/default.py | 21 | 
6 files changed, 263 insertions, 0 deletions
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py index aa1d5c3..a727230 100644 --- a/fietsboek/__init__.py +++ b/fietsboek/__init__.py @@ -12,6 +12,7 @@ from pyramid.i18n import default_locale_negotiator  from .security import SecurityPolicy  from .data import DataManager +from .pages import Pages  from . import jinja2 as fiets_jinja2 @@ -60,6 +61,20 @@ def main(global_config, **settings):          settings.get('enable_account_registration', 'false'))      settings['available_locales'] = aslist(          settings.get('available_locales', 'en')) +    settings['fietsboek.pages'] = aslist( +        settings.get('fietsboek.pages', '')) + +    # Load the pages +    page_manager = Pages() +    for path in settings['fietsboek.pages']: +        path = Path(path) +        if path.is_dir(): +            page_manager.load_directory(path) +        elif path.is_file(): +            page_manager.load_file(path) + +    def pages(request): +        return page_manager      my_session_factory = SignedCookieSessionFactory(settings['session_key'])      with Configurator(settings=settings) as config: @@ -74,6 +89,7 @@ def main(global_config, **settings):          config.set_default_csrf_options(require_csrf=True)          config.set_locale_negotiator(locale_negotiator)          config.add_request_method(data_manager, reify=True) +        config.add_request_method(pages, reify=True)      jinja2_env = config.get_jinja2_environment()      jinja2_env.filters['format_decimal'] = fiets_jinja2.filter_format_decimal diff --git a/fietsboek/pages.py b/fietsboek/pages.py new file mode 100644 index 0000000..5556038 --- /dev/null +++ b/fietsboek/pages.py @@ -0,0 +1,208 @@ +"""Module containing logic to support "static" pages.""" +import bisect +import enum +import re + +import markdown + + +class PageException(Exception): +    """Exception that is raised when parsing a :class:`Page` fails.""" + + +class UserFilter(enum.Enum): +    """Filter that determines to which users a page should be shown.""" + +    LOGGED_IN = enum.auto() +    """Shows the page only to logged in users.""" + +    LOGGED_OUT = enum.auto() +    """Shows the page only to logged in users.""" + +    EVERYONE = enum.auto() +    """Shows the page to everyone.""" + + +class Page: +    """Represents a loaded page. + +    :ivar slug: The page's slug (URL identifier) +    :vartype slug: str +    :ivar content: The page's content as HTML. +    :vartype content: str +    :ivar link_name: The name of the site to show in the menu. +    :vartype link_name: str +    :ivar locale_filter: An (optional) pattern that determines whether the page +        should be shown to certain locales only. +    :vartype locale_filter: list[re.Pattern] +    :ivar user_filter: A filter that determines if the page should be shown to +        certain users only. +    :vartype user_filter: UserFilter +    :ivar menu_index: The index in the menu. +    :vartype menu_index: int +    """ + +    def __init__(self, slug, title, content, link_name, locale_filter=None, +                 user_filter=UserFilter.EVERYONE, menu_index=0): +        # pylint: disable=too-many-arguments +        self.slug = slug +        self.title = title +        self.content = content +        self.link_name = link_name +        self.locale_filter = locale_filter +        self.user_filter = user_filter +        self.menu_index = menu_index + +    def matches(self, request): +        """Checks whether the page matches the given request regarding the +        locale and user filters. + +        :param request: The request to check against. +        :type request: pyramid.request.Request +        :return: Whether the page matches the user. +        :rtype: bool +        """ +        if self.user_filter == UserFilter.LOGGED_IN and not request.identity: +            return False +        if self.user_filter == UserFilter.LOGGED_OUT and request.identity: +            return False + +        if self.locale_filter is not None: +            return any( +                lfilter.match(request.localizer.locale_name) +                for lfilter in self.locale_filter +            ) + +        return True + +    @classmethod +    def parse(cls, text): +        """Parses a :class:`Page` from the given textual source. + +        This populates all metadata with the metadata from the file. + +        :raises PageException: If there are missing metadata fields. +        :param text: Source to parse. +        :type text: str +        :return: The parsed page. +        :rtype: Page +        """ +        # Pylint doesn't know about the Markdown extensions: +        # pylint: disable=no-member +        parser = markdown.Markdown(extensions=["meta"]) +        content = parser.convert(text) + +        title = parser.Meta.get('title', [''])[0] +        if not title: +            raise PageException("Missing `title`") + +        link_name = parser.Meta.get('link-name', [''])[0] +        if not link_name: +            raise PageException("Missing `link-name`") + +        slug = parser.Meta.get('slug', [''])[0] +        if not slug: +            raise PageException("Missing `slug`") + +        try: +            locale_filter = list(map(re.compile, parser.Meta.get('locale', []))) +        except re.error as exc: +            raise PageException("Invalid locale regex") from exc +        if not locale_filter: +            locale_filter = None + +        filter_map = { +            'logged-in': UserFilter.LOGGED_IN, +            'logged-out': UserFilter.LOGGED_OUT, +            'everyone': UserFilter.EVERYONE, +        } +        user_filter = filter_map.get(parser.Meta.get('show-to', ['everyone'])[0].lower()) +        if user_filter is None: +            raise PageException("Invalid `show-to` filter") + +        try: +            menu_index = int(parser.Meta.get('index', ['0'])[0]) +        except ValueError as exc: +            raise PageException("Invalid value for `index`") from exc + +        return Page(slug, title, content, link_name, locale_filter, user_filter, menu_index) + + +class Pages: +    """A class that loads static pages from paths and providing easy access for +    other parts of Fietsboek.""" + +    def __init__(self): +        self.collection = [] + +    def load_file(self, path): +        """Load a page from a file. + +        :param path: The path of the file to load. +        :type path: pathlib.Path +        :raises PageException: If the page is malformed. +        """ +        source = path.read_text() +        try: +            page = Page.parse(source) +        except PageException as exc: +            raise PageException(f"Error reading `{path}`: {exc}") from None +        bisect.insort(self.collection, page, key=lambda page: page.menu_index) + +    def load_directory(self, path): +        """Load a directory full of pages. + +        This attemps to load and file in the given directory ending with ".md". + +        :param path: The path of the directory to load. +        :type path: pathlib.Path +        :raises PageException: If a page is malformed. +        """ +        for child in path.glob("*.md"): +            self.load_file(child) + +    def find(self, slug, request=None): +        """Finds the page matching the given slug. + +        If a request is given, the filtering based on locale/logged in state is applied. + +        If multiple pages are found, the first found one is returned. If no +        page is found, ``None`` is returned. + +        :param slug: The slug of the page: +        :type slug: str +        :param request: The request to filter against. +        :type request: pyramid.request.Request +        :return: The page, if any is found. +        :rtype: Page +        """ +        for page in self.collection: +            if page.slug == slug and (request is None or page.matches(request)): +                return page +        return None + +    def pre_menu_items(self, request): +        """Return all items that should appear before Fietsboek's main menu. + +        :param request: The request to filter against. +        :type request: pyramid.request.Request +        :return: A list of menu entries to show. +        :rtype: list[Page] +        """ +        return [ +            page for page in self.collection +            if page.menu_index < 0 and page.matches(request) +        ] + +    def post_menu_items(self, request): +        """Return all items that should appear after Fietsboek's main menu. + +        :param request: The request to filter against. +        :type request: pyramid.request.Request +        :return: A list of menu entries to show. +        :rtype: list[Page] +        """ +        return [ +            page for page in self.collection +            if page.menu_index > 0 and page.matches(request) +        ] diff --git a/fietsboek/routes.py b/fietsboek/routes.py index 9d705c1..ab1eabf 100644 --- a/fietsboek/routes.py +++ b/fietsboek/routes.py @@ -9,6 +9,8 @@ def includeme(config):      config.add_route('logout', '/logout')      config.add_route('browse', '/track/') +    config.add_route('static-page', '/page/{slug}') +      config.add_route('track-archive', '/track/archive')      config.add_route('password-reset', '/password-reset') diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2 index 0e43a36..7371052 100644 --- a/fietsboek/templates/layout.jinja2 +++ b/fietsboek/templates/layout.jinja2 @@ -42,6 +42,11 @@ window.JB.GPX2GM.OSM_Outdoors_Api_Key = {{ api_key | tojson }};          </button>          <div class="collapse navbar-collapse" id="navbar">            <ul class="navbar-nav w-100 mb-2 mb-lg-0"> +            {% for page in request.pages.pre_menu_items(request) %} +            <li class="nav-item"> +              <a class="nav-link" href="{{ request.route_url('static-page', slug=page.slug) }}">{{ page.link_name }}</a> +            </li> +            {% endfor %}              <li class="nav-item">                <a class="nav-link" href="{{ request.route_url('home') }}">{{ _("page.navbar.home") }}</a>              </li> @@ -53,6 +58,11 @@ window.JB.GPX2GM.OSM_Outdoors_Api_Key = {{ api_key | tojson }};                <a class="nav-link" href="{{ request.route_url('upload') }}">{{ _("page.navbar.upload") }}</a>              </li>              {% endif %} +            {% for page in request.pages.post_menu_items(request) %} +            <li class="nav-item"> +              <a class="nav-link" href="{{ request.route_url('static-page', slug=page.slug) }}">{{ page.link_name }}</a> +            </li> +            {% endfor %}              <li class="nav-item ms-lg-auto dropdown">                <a class="nav-link dropdown-toggle" id="navbarUserMenu" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">{{ _("page.navbar.user") }}</a>                <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarUserMenu"> diff --git a/fietsboek/templates/static-page.jinja2 b/fietsboek/templates/static-page.jinja2 new file mode 100644 index 0000000..a344158 --- /dev/null +++ b/fietsboek/templates/static-page.jinja2 @@ -0,0 +1,6 @@ +{% extends "layout.jinja2" %} +{% block content %} +<div class="container"> +    {{ content }} +</div> +{% endblock %} diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py index de59f06..f61b95c 100644 --- a/fietsboek/views/default.py +++ b/fietsboek/views/default.py @@ -9,6 +9,8 @@ from sqlalchemy import select  from sqlalchemy.orm import aliased  from sqlalchemy.exc import NoResultFound +from markupsafe import Markup +  from .. import models, summaries, util, email  from ..models.user import PasswordMismatch, TokenType  from ..models.track import TrackType @@ -45,6 +47,25 @@ def home(request):      } +@view_config(route_name='static-page', renderer='fietsboek:templates/static-page.jinja2') +def static_page(request): +    """Renders a static page. + +    :param request: The Pyramid request. +    :type request: pyramid.request.Request +    :return: The HTTP response. +    :rtype: pyramid.response.Response +    """ +    page = request.pages.find(request.matchdict['slug'], request) +    if page is None: +        return HTTPNotFound() + +    return { +        'title': page.title, +        'content': Markup(page.content), +    } + +  @view_config(route_name='login', renderer='fietsboek:templates/login.jinja2', request_method='GET')  def login(request):      """Renders the login page.  | 
