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. |