aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--doc/administration.rst1
-rw-r--r--doc/administration/configuration.rst1
-rw-r--r--doc/administration/custom-pages.rst116
-rw-r--r--fietsboek/__init__.py16
-rw-r--r--fietsboek/pages.py208
-rw-r--r--fietsboek/routes.py2
-rw-r--r--fietsboek/templates/layout.jinja210
-rw-r--r--fietsboek/templates/static-page.jinja29
-rw-r--r--fietsboek/views/default.py21
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.