aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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.jinja26
-rw-r--r--fietsboek/views/default.py21
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.