Skip to content

Commit

Permalink
Merge branch 'page_refactor' into develop
Browse files Browse the repository at this point in the history
  • Loading branch information
lukasjuhrich committed Oct 18, 2023
2 parents bc2f3c8 + da798e5 commit f2b06be
Show file tree
Hide file tree
Showing 9 changed files with 193 additions and 132 deletions.
20 changes: 19 additions & 1 deletion sipa/babel.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from __future__ import annotations

import logging
import typing as t

from babel import Locale, UnknownLocaleError, negotiate_locale
from flask import request, session
from flask import request, session, Request, g
from werkzeug.exceptions import BadRequest

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -70,3 +73,18 @@ def select_locale() -> str:
return negotiate_locale(
request.accept_languages.values(),
list(map(str, possible_locales())), sep='-')


def iter_preferred_locales(request: Request) -> t.Iterator[str]:
if (user_locale := str(get_user_locale_setting())) is not None:
yield user_locale
yield from request.accept_languages.values()


def preferred_locales() -> list[str]:
return g.preferred_locales


def cache_preferred_locales(*a, **extra):
"""Store the preferred locales on the `g` object."""
g.preferred_locales = list(iter_preferred_locales(request))
25 changes: 24 additions & 1 deletion sipa/blueprints/features.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
and does not fit into any other blueprint such as “documents”.
"""

from flask import Blueprint, current_app, render_template
from flask import Blueprint, current_app, render_template, render_template_string

from sipa.utils import get_bustimes, meetingcal

bp_features = Blueprint('features', __name__)
Expand Down Expand Up @@ -33,3 +34,25 @@ def bustimes(stopname=None):
def render_meetingcal():
meetings = meetingcal()
return render_template('meetingcal.html', meetings=meetings)


@bp_features.route("/meetings-fragment")
def meetings():
return render_template_string(
"""
{%- from "macros/meetingcal.html" import render_meetingcal -%}
{{- render_meetingcal(meetingcal) -}}
""",
meetingcal=meetingcal(),
)


@bp_features.route("/hotline-fragment")
def hotline():
return render_template_string(
"""
{%- from "macros/support-hotline.html" import hotline_description -%}
{{- hotline_description(available=available) -}}
""",
available=True,
)
3 changes: 1 addition & 2 deletions sipa/blueprints/news.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ def show():
start = request.args.get('start', None, int)
end = request.args.get('end', None, int)
cf_pages = current_app.cf_pages
cf_pages.reload()
news = sorted(
(article for article in cf_pages.get_articles_of_category('news')
if hasattr(article, 'date')),
Expand Down Expand Up @@ -92,9 +91,9 @@ def try_get_content(cf_pages: CategorizedFlatPages, filename: str) -> str:
"""Reconstructs the content of a news article from the given filename."""
news = cf_pages.get_articles_of_category("news")
article = next((a for a in news if a.file_basename == filename), None)
assert isinstance(article, Article)
if not article:
return ""
assert isinstance(article, Article)
p = article.localized_page
# need to reconstruct actual content; only have access to parsed form
return p._meta + "\n\n" + p.body
Expand Down
136 changes: 80 additions & 56 deletions sipa/flatpages.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
from __future__ import annotations

import logging
from dataclasses import dataclass, field
from functools import cached_property, lru_cache
from operator import attrgetter
from os.path import basename, dirname, splitext
from typing import Any

from babel.core import Locale, UnknownLocaleError, negotiate_locale
from flask import abort, request
from flask_flatpages import FlatPages, Page
from yaml.scanner import ScannerError

from sipa.babel import get_user_locale_setting, possible_locales
from sipa.babel import possible_locales, preferred_locales

logger = logging.getLogger(__name__)


@lru_cache(maxsize=128)
def cached_negotiate_locale(
preferred_locales: tuple[str], available_locales: tuple[str]
) -> str | None:
return negotiate_locale(
preferred_locales,
available_locales,
sep="-",
)


# NB: Node is meant to be a union `Article | Category`.
@dataclass
class Node:
"""An abstract object with a parent and an id"""

def __init__(self, extension, parent, node_id):
#: The CategorizedFlatPages extension
self.extension = extension
#: The parent object
self.parent = parent
#: This object's id
self.id = node_id
parent: Category | None
id: str

#: Only used for initialization.
#: determines the default page of an article.
default_locale: Locale


@dataclass
class Article(Node):
"""The Article class
Expand All @@ -40,14 +56,13 @@ class Article(Node):
Besides that, :py:meth:`__getattr__` comfortably passes queries to
the :py:obj:`localized_page.meta` dict.
"""
def __init__(self, extension, parent, article_id):
super().__init__(extension, parent, article_id)
#: The dict containing the localized pages of this article
self.localized_pages: dict[Any, Page] = {}
#: The default page
self.default_page: Page = None

def add_page(self, page: Page, locale: Locale):

#: The dict containing the localized pages of this article
localized_pages: dict[str, Page] = field(init=False, default_factory=dict)
#: The default page
default_page: Page | None = field(init=False, default=None)

def add_page(self, page: Page, locale: Locale) -> None:
"""Add a page to the pages list.
If the name is not ``index`` and the validation via
Expand All @@ -63,31 +78,13 @@ def add_page(self, page: Page, locale: Locale):
:param page: The page to add
:param locale: The locale of this page
"""
if not (self.id == 'index' or self.validate_page_meta(page)):
if not (self.id == "index" or validate_page_meta(page)):
return

self.localized_pages[str(locale)] = page
default_locale = self.extension.app.babel_instance.default_locale
if self.default_page is None or locale == default_locale:
if self.default_page is None or locale == self.default_locale:
self.default_page = page

@staticmethod
def validate_page_meta(page: Page) -> bool:
"""Validate that the pages meta-section.
This function is necessary because a page with incorrect
metadata will raise some Errors when trying to access them.
Note that this is done rather early as pages are cached.
:param page: The page to validate
:returns: Whether the page is valid
"""
try:
return 'title' in page.meta
except ScannerError:
return False

@property
def rank(self) -> int:
"""The rank of the :py:attr:`localized_page`
Expand Down Expand Up @@ -155,6 +152,10 @@ def __getattr__(self, attr: str) -> str:
"{!r} object has no attribute {!r}"
.format(type(self).__name__, attr)) from e

@cached_property
def available_locales(self) -> tuple[str]:
return tuple(self.localized_pages.keys())

@property
def localized_page(self) -> Page:
"""The current localized page
Expand All @@ -165,17 +166,10 @@ def localized_page(self) -> Page:
:returns: The localized page
"""
available_locales = list(self.localized_pages.keys())

user_locale = str(get_user_locale_setting())
if user_locale is None:
preferred_locales = []
else:
preferred_locales = [user_locale]
preferred_locales.extend(request.accept_languages.values())

negotiated_locale = negotiate_locale(
preferred_locales, available_locales, sep='-')
negotiated_locale = cached_negotiate_locale(
tuple(preferred_locales()),
self.available_locales,
)
if negotiated_locale is not None:
return self.localized_pages[negotiated_locale]
return self.default_page
Expand All @@ -191,17 +185,34 @@ def file_basename(self) -> str:
return splitext(basename(self.localized_page.path))[0]


def validate_page_meta(page: Page) -> bool:
"""Validate that the pages meta-section.
This function is necessary because a page with incorrect
metadata will raise some Errors when trying to access them.
Note that this is done rather early as pages are cached.
:param page: The page to validate
:returns: Whether the page is valid
"""
try:
return "title" in page.meta
except ScannerError:
return False


@dataclass
class Category(Node):
"""The Category class
* What's it used for?
- Containing articles → should be iterable!
"""
def __init__(self, extension, parent, category_id):
super().__init__(extension, parent, category_id)
self.categories = {}
self._articles = {}

categories: dict = field(init=False, default_factory=dict)
_articles: dict = field(init=False, default_factory=dict)

@property
def articles(self):
Expand Down Expand Up @@ -233,7 +244,11 @@ def add_child_category(self, id):
if category is not None:
return category

category = Category(self.extension, self, id)
category = Category(
parent=self,
id=id,
default_locale=self.default_locale,
)
self.categories[id] = category
return category

Expand All @@ -248,7 +263,7 @@ def _parse_page_basename(self, basename):
:return: The tuple `(article_id, locale)`.
"""
default_locale = self.extension.app.babel_instance.default_locale
default_locale = self.default_locale
article_id, sep, locale_identifier = basename.rpartition('.')

if sep == '':
Expand Down Expand Up @@ -278,7 +293,11 @@ def add_article(self, prefix, page):

article = self._articles.get(article_id)
if article is None:
article = Article(self.extension, self, article_id)
article = Article(
parent=self,
id=article_id,
default_locale=self.default_locale,
)
self._articles[article_id] = article

article.add_page(page, locale)
Expand All @@ -295,7 +314,7 @@ class CategorizedFlatPages:
"""
def __init__(self):
self.flat_pages = FlatPages()
self.root_category = Category(self, None, '<root>')
self.root_category = None
self.app = None

def init_app(self, app):
Expand All @@ -304,6 +323,11 @@ def init_app(self, app):
self.app = app
app.cf_pages = self
self.flat_pages.init_app(app)
self.root_category = Category(
parent=None,
id="<root>",
default_locale=app.babel_instance.default_locale,
)
self._init_categories()

@property
Expand Down
29 changes: 9 additions & 20 deletions sipa/initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
from datetime import datetime

import sentry_sdk
from flask import g
from flask import g, request_started
from flask_babel import Babel, get_locale
from werkzeug import Response
from werkzeug.middleware.proxy_fix import ProxyFix
from flask_qrcode import QRcode
from sentry_sdk.integrations.flask import FlaskIntegration

from sipa.babel import possible_locales, save_user_locale_setting, select_locale
from sipa.babel import (
possible_locales,
save_user_locale_setting,
select_locale,
cache_preferred_locales,
)
from sipa.backends import Backends
from sipa.base import IntegerConverter, login_manager
from sipa.blueprints.usersuite import get_attribute_endpoint
Expand All @@ -22,7 +27,7 @@
from sipa.model import AVAILABLE_DATASOURCES
from sipa.model.misc import should_display_traffic_data
from sipa.session import SeparateLocaleCookieSessionInterface
from sipa.utils import url_self, support_hotline_available, meetingcal
from sipa.utils import url_self
from sipa.utils.babel_utils import get_weekday
from sipa.utils.csp import ensure_items, NonceInfo
from sipa.utils.git_utils import init_repo, update_repo
Expand Down Expand Up @@ -51,6 +56,7 @@ def init_app(app, **kwargs):
babel = Babel()
babel.init_app(app)
babel.localeselector(select_locale)
request_started.connect(cache_preferred_locales)
app.before_request(save_user_locale_setting)
app.after_request(ensure_csp)
app.session_interface = SeparateLocaleCookieSessionInterface()
Expand Down Expand Up @@ -93,11 +99,6 @@ def init_app(app, **kwargs):
url_self=url_self,
now=datetime.utcnow()
)
# the functions could also directly be added as globals,
# however to minimize the diff things have been kept this way.
app.context_processor(inject_hotline_status)
app.context_processor(inject_meetingcal)

app.add_template_filter(render_links)

def glyphicon_to_bi(glyphicon: str) -> str:
Expand Down Expand Up @@ -134,18 +135,6 @@ def glyphicon_to_bi(glyphicon: str) -> str:
extra={'data': {'jinja_globals': app.jinja_env.globals}})


def inject_hotline_status():
"""Adds :func:`support_hotline_available <sipa.utils.support_hotline_available>`
to the :class:`jinja context <jinja2.runtime.Context>`"""
return dict(support_hotline_available=support_hotline_available())


def inject_meetingcal():
"""Adds :func:`meetingcal <sipa.utils.meetingcal>`
to the :class:`jinja context <jinja2.runtime.Context>`"""
return dict(meetingcal=meetingcal())


def load_config_file(app, config=None):
"""Just load the config file, do nothing else"""
# default configuration
Expand Down
Loading

0 comments on commit f2b06be

Please sign in to comment.