diff options
-rw-r--r-- | doc/administration.rst | 1 | ||||
-rw-r--r-- | doc/administration/configuration.rst | 1 | ||||
-rw-r--r-- | doc/administration/custom-pages.rst | 116 | ||||
-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 | 9 | ||||
-rw-r--r-- | fietsboek/views/default.py | 21 |
9 files changed, 384 insertions, 0 deletions
diff --git a/doc/administration.rst b/doc/administration.rst index 3c5cf7f..7aa2393 100644 --- a/doc/administration.rst +++ b/doc/administration.rst @@ -9,6 +9,7 @@ Administration Guide administration/configuration administration/upgrading administration/backup + administration/custom-pages This guide contains information pertaining to the administration of a Fietsboek instance. This includes the installation, updating, configuration, and backing diff --git a/doc/administration/configuration.rst b/doc/administration/configuration.rst index 272ac71..5268903 100644 --- a/doc/administration/configuration.rst +++ b/doc/administration/configuration.rst @@ -61,6 +61,7 @@ Most of the configuration is in the ``[app:main]`` category and looks like this: * ``fietsboek.data_dir`` sets the directory for data uploads. This directory must be writable by the Fietsboek process, as Fietsboek will save track images in there. +* ``fietsboek.pages`` see :doc:`custom-pages`. * ``email.from`` sets the sender of emails, for example for account verifications. * ``email.smtp_url`` sets the URL of the SMTP server. The following formats are accepted: diff --git a/doc/administration/custom-pages.rst b/doc/administration/custom-pages.rst new file mode 100644 index 0000000..81354d5 --- /dev/null +++ b/doc/administration/custom-pages.rst @@ -0,0 +1,116 @@ +Custom Pages +############ + +Sometimes it is necessary to add custom static content to your website. For +example, a legal requirement might be to have an `Impressum +<https://en.wikipedia.org/wiki/Impressum>`__, a privacy policy, contact +information or similar things on a website. Or you simply want to add some +information about your instance of Fietsboek, links to other sites, ... + +Such pages can not be provided by Fietsboek out of the box, as they are very +customized to the particular installation, and as such, Fietsboek provides a +way to include custom pages. Those pages have the benefit of being embedded in +Fietsboek's menu and layout, so they don't look out of place and can easily be +found. + +.. note:: + + Please note that Fietsboek is not a general purpose content management + system for text. As such, the functionality is rather rudimentary and meant + for basic tasks such as the aforementioned legal documents. + + Complex documents can always be done outside of Fietsboek, or by modifying + Fietsboek's source in your local installation. + +Writing A Page +-------------- + +Pages are written in Markdown and as such support some basic formatting +functionality [1]_. In addition to the content, a page also has some metadata +that tell Fietsboek when to show it, where to place it in the menu, ... + +An example page could look like this: + +.. code:: markdown + + Title: Open Source + Link-name: Open Source + Slug: open-source + Locale: en + Show-to: everyone + Index: 1 + + # Fietsboek is Open Source! + + You can contribute to **Fietsboek** [on Gitlab](https://gitlab.com/dunj3/fietsboek)! + +The metadata is provided in form of ``Key: value`` attributes at the start of +the file. The rest of the file is interpreted as Markdown and rendered to HTML. + +The supported attributes are: + +Title : required + The title of the page, as it should be shown in the title bar of the browser. + +Link-name : required + The name of the link, as it is rendered in the menu. + +Slug : required + The slug of the page, as it appears in the URL. The page is reachable under + ``https://{your-host}/page/{page-slug}``. + +Locale : optional + Optional filter for the page locale. The filter is given as a regular + expression, the page is only shown if the expression matches the user's + locale. Multiple locale filters can be given, in which case any of them + have to match in order for the page to be shown. + +Show-to : optional + Determines whether the page should be shown to everyone (``everyone``), + only to logged in users (``logged-in``) or only to logged out users + (``logged-out``). + +Index : optional + Determines the position of the item in the menu. A higher index means the + item is more to the right. Pages with negative index are left of + Fietsboek's own menu, pages with positive index are right of Fietsboek's + own menu, and pages with index 0 (the default) are not rendered in the menu + (but still accessible via the link). + +The source code of a page (including the metadata and attributes) should be +saved at a path that is accessible by Fietsboek. + + +Including Pages +--------------- + +After you have written and saved the page, you can include it via the +configuration file. The key ``fietsboek.pages`` is a list of paths to pages: + +.. code:: ini + + [app:main] + # ... + fietsboek.pages = + /fietsboek/pages/page1.md + /fietsboek/pages/page2.md + +You can also include a directory, in which case all ``.md`` files of that +directory will be included: + +.. code:: ini + + [app:main] + # ... + fietsboek.pages = + /fietsboek/pages/ + +Tips & Tricks +------------- + +You can use the ``Locale`` filter to provide localized versions of your pages. +If you keep the ``Slug`` the same between different languages, you can even +re-use the same URL! + +.. [1] A basic syntax overview is for example here: + https://www.markdownguide.org/basic-syntax/ 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..3a7e3c0 --- /dev/null +++ b/fietsboek/templates/static-page.jinja2 @@ -0,0 +1,9 @@ +{% extends "layout.jinja2" %} +{% block title %} +{{ title }} - Fietsboek +{% endblock %} +{% 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. |