pdnew/rendering.py

522 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# builtins
import math
import os
import re
# third-party
import markupsafe
import werkzeug
import flask
import peewee # for type checking
# internals
from application import app
import util
def page(cache_disable=False, **template_params):
"""
Decorator for endpoint functions to render the content returned by the
endpoint function within a proper layout.
Takes keyword arguments that will be passed into the layout template.
If the returned content is a Renderable, the layout will render it
with the passed `mode` if any or 'full' mode otherwise.
ex:
```
@app.route('/some/path/')
@page(title="Some page")
def some_endpoint():
return "Some content"
```
"""
def decorator(f):
def wrapper(*args, **kwargs):
agent = flask.request.headers.get('User-Agent')
params = {
'site_name': app.config['SITE_NAME'],
'user': flask.g.user,
'tls_cipher': flask.g.tls_cipher,
'client_ua': agent,
'client_ua_noadblock': agent_noadblock(agent),
'client_cert_verified': flask.g.client_cert_verified,
'client_cert_fingerprint': flask.g.client_cert_fingerprint,
'client_cert_fingerprint_matched': flask.g.client_cert_fingerprint_matched,
}
params.update(template_params)
if 'format' not in params:
params['format'] = 'html'
if 'layout' in kwargs:
# explicitly chosen layout
template_candidates = [
f'{app.config["THEME"]}/templates/layout/{params["layout"]}.{params["format"]}',
]
if app.config['THEME'] != 'default':
template_candidates.append(f'default/templates/layout/{params["layout"]}.{params["format"]}')
else:
# choose layout by endpoint name if a fit exists,
# otherwise default to 'main'.
template_candidates = [
f'{app.config["THEME"]}/templates/layout/{f.__name__}.{params["format"]}',
f'{app.config["THEME"]}/templates/layout/main.{params["format"]}',
]
if app.config['THEME'] != 'default':
template_candidates.append(f'default/templates/layout/{f.__name__}.{params["format"]}')
template_candidates.append('default/templates/layout/main.{params["format"]}')
content = f(*args, **kwargs)
if isinstance(content, werkzeug.Response):
# if the original return value of the decorated function is
# a response object, return it as-is useful for redirects
return content
params['content'] = content
if flask.request.blueprint:
blueprint = app.blueprints[flask.request.blueprint]
else:
blueprint = None
for menu_name in app.config['MENUS']:
try:
if blueprint and blueprint.menu_exists(menu_name):
params[f'menu_{menu_name}'] = blueprint.menu_get(menu_name)
elif app.menu_exists(menu_name):
params[f'menu_{menu_name}'] = app.menu_get(menu_name)
except Exception as e:
if app.debug:
raise
app.logger.error(f"Error generating menu '{menu_name}': {str(e)}")
block_source = blueprint or app
for block_name in block_source.block_functions:
try:
params[f'block_{block_name}'] = block_source.block_get(block_name)
except Exception as e:
if app.debug:
raise
app.logger.error(f"(Error generating block '{block_name}': {str(e)}")
if 'mode' not in params:
params['mode'] = 'full'
if 'title' not in params: # means it was passed directly in **template_params
if type(content) != str and\
hasattr(content, 'title') and\
content.title is not None:
params['title'] = content.title
else:
params['title'] = None
if 'title_show' not in params:
params['title_show'] = True
status = content.status if isinstance(content, ErrorPage) else 200
body = flask.render_template(
template_candidates,
**params
)
response = flask.Response(body, status)
if params['format'] in app.config['PAGE_CONTENT_TYPES']:
response.headers['Content-Type'] = app.config['PAGE_CONTENT_TYPES'][params['format']]
if cache_disable or flask.g.user or flask.request.method not in ('GET', 'HEAD'):
response.cache_control.no_cache = True
response.cache_control.private = None
response.cache_control.public = None
response.cache_control.max_age = 0
response.cache_control.s_maxage = 0
else:
response.cache_control.no_cache = None
response.cache_control.private = True
response.cache_control.public = True
response.cache_control.max_age = 120
response.cache_control.s_maxage = 15
return response
wrapper.__name__ = f.__name__ # avoid name collisions in routing data
return wrapper
return decorator
noadblock_progs = [
re.compile('.*Android.*Chrome\/'), # no built-in adblocker, can't install extensions
]
def agent_noadblock(agent):
# Determine whether the given user agent has no ability to add an adblocker.
for prog in noadblock_progs:
if prog.match(agent):
return True
return False
@app.template_test('renderable')
def test_renderable(obj):
return isinstance(obj, Renderable)
@app.route('/theme/<path:asset>')
def serve_asset(asset):
asset_parts = asset.split('/')
filename = asset_parts[-1]
subdirectory_parts = asset_parts[:-1]
subdirectory = '/'.join(subdirectory_parts)
directory_candidates = [
os.path.join('themes', app.config['THEME'], 'assets', subdirectory)
]
if app.config['THEME'] != 'default':
directory_candidates.append(os.path.join('themes', 'default', 'assets', subdirectory))
for directory_candidate in directory_candidates:
path_candidate = os.path.join(directory_candidate, filename)
if os.path.exists(path_candidate):
response = flask.send_from_directory(directory_candidate, filename)
response.cache_control.no_cache = None
response.cache_control.public = True
response.cache_control.private = True
response.cache_control.max_age = 15 * 60
response.cache_control.s_maxage = 5 * 60
return response
flask.abort(404)
class Renderable(util.ChildAware):
def __init__(self, extra_classes=None):
self.extra_classes = extra_classes or []
@classmethod
def load(cls, **kwargs):
# this is essentially an alias for instantiation to keep consistent
# with Administerable.load
return cls(**kwargs)
def template_candidates(self, mode, format):
candidates = []
for cls in self.__class__.mro():
if issubclass(cls, Renderable):
candidates += [
f'{app.config["THEME"]}/templates/renderable/{cls._lowerclass}-{mode}.{format}',
f'{app.config["THEME"]}/templates/renderable/{cls._lowerclass}.{format}',
]
if app.config['THEME'] != 'default':
for cls in self.__class__.mro():
if issubclass(cls, Renderable):
candidates += [
f'default/templates/renderable/{self._lowerclass}-{mode}.{format}',
f'default/templates/renderable/{self._lowerclass}.{format}',
]
return candidates
@property
def css_classes(self):
classes = []
for cls in self.__class__.mro():
if issubclass(cls, Renderable):
classes.append(cls._lowerclass)
return ' '.join([*classes, *self.extra_classes])
@classmethod
def view(cls, mode='full', **kwargs):
@page(mode=mode)
def wrapped(**kw):
instance = cls(**kw)
if not instance.access(mode):
flask.abort(403)
return instance
return wrapped(**kwargs)
def access(self, mode='full'):
return True
def render(self, mode='full', format='html'):
return markupsafe.Markup(flask.render_template(
self.template_candidates(mode, format),
content=self,
mode=mode,
format=format,
))
class Menu(Renderable):
def __init__(self, name, items=None, burger=False, **kwargs):
super().__init__(**kwargs)
self.name = name
self.items = []
self.burger = burger
if items:
for item in items:
self.append(**item)
@property
def css_classes(self):
string = super().css_classes
if self.burger:
string += ' with-burger'
return string
def append(self, url=None, label=None, endpoint=None, params=None, classes=None):
if not url and not endpoint:
raise TypeError("Menu items MUST include either 'url' or 'endpoint'.")
if url and endpoint:
raise TypeError("Menu items MUST include either 'url' or 'endpoint', but not both.")
if url and params:
raise TypeError("Menu items with 'url' MUST NOT include 'params', which is for endpoint items only.")
if not label:
raise TypeError("Menu items MUST include 'label'.")
if endpoint:
if params is None:
params = {}
url = flask.url_for(endpoint, **params)
state = 'inactive'
if flask.has_request_context():
if flask.request.url_rule is not None: # ignore requests for which no route exists
if flask.request.url_rule.endpoint == endpoint:
state = 'active'
elif endpoint != 'frontpage': # determine trailing status, but not for "home" page.
if flask.request.path.startswith(url):
state = 'trail'
else:
state = 'inactive'
self.items.append({
'url': url,
'endpoint': endpoint,
'state': state,
'label': label,
'classes': classes
})
class Pagination(Renderable):
def __init__(self, items, endpoint, endpoint_params=None, offset=0, limit=None, **kwargs):
super().__init__(**kwargs)
self.items_all = items
self.items_count = len(self.items_all)
self.offset = offset
self.limit = limit or app.config['PAGINATION_LIMIT']
self.page_count = math.ceil(self.items_count / self.limit)
self.page_current = int(self.offset / self.limit) + 1
self.page_spread = app.config['PAGINATION_SPREAD']
if self.page_spread > self.page_count:
self.page_spread = self.page_count
# no menu if its just a single page
if self.page_count <= 1:
self.items = self.items_all
self.menu = None
else:
if isinstance(items, peewee.Query):
# if items are actually a database query, we apply offset and limit
self.items = self.items_all.offset(self.offset).limit(self.limit)
else:
# otherwise we do the equivalent for a list/iterable
if self.offset >= self.items_count:
self.offset = self.items_count -1 # go to last item if offset overshoots
if self.items_count > self.offset + self.limit:
self.items = self.items_all[self.offset:self.offset+self.limit]
else:
self.items = self.items_all[self.offset:]
page_prev = (self.page_current - 1) or 1
page_next = self.page_current + 1 if self.page_current < self.page_count else self.page_count
page_range = None
page_spread_per_side = math.floor(self.page_spread / 2)
if self.page_current <= page_spread_per_side:
# range touches start
page_range = range(1, 1 + self.page_spread)
elif self.page_current + page_spread_per_side >= self.page_count:
# range touches end
page_range = range(self.page_count - self.page_spread + 1, self.page_count + 1)
else:
# range in the middle, not touching either end
page_range = range(
self.page_current - math.floor(self.page_spread / 2),
self.page_current + math.ceil(self.page_spread / 2)
)
if not endpoint_params:
endpoint_params = {}
self.menu = Menu('pagination') # NOTE: menu name is not unique
self.menu.append(
endpoint=endpoint,
label='',
params = {
'offset': 0,
**endpoint_params,
},
classes='first current-page' if self.page_current == 1 else 'first'
)
self.menu.append(
endpoint=endpoint,
label='',
params={
'offset': (page_prev - 1) * self.limit,
**endpoint_params,
},
classes='previous current-page' if self.page_current == 1 else 'previous'
)
for page in page_range:
self.menu.append(
endpoint=endpoint,
label=page,
params={
'offset': (page - 1) * self.limit,
**endpoint_params,
},
classes='current-page' if page == self.page_current else ''
)
self.menu.append(
endpoint=endpoint,
label='',
params={
'offset': (page_next - 1) * self.limit,
**endpoint_params,
},
classes='next current-page' if self.page_current == self.page_count else 'next'
)
self.menu.append(
endpoint=endpoint,
label='',
params={
'offset': (self.page_count - 1) * self.limit,
**endpoint_params,
},
classes='last current-page' if self.page_current == self.page_count else 'last'
)
class Listing(Renderable):
def __init__(self, items, offset=0, limit=None, title=None, mode='teaser', menu=None, endpoint=None, endpoint_params=None, item_constructor=None, **kwargs):
super().__init__(**kwargs)
self.items = items
self.title = title
self.mode = mode
self.menu = menu
if endpoint:
self.pagination = Pagination(items, endpoint, endpoint_params=endpoint_params, offset=offset, limit=limit)
self.items = self.pagination.items
else:
self.pagination = None
if item_constructor:
self.items = [item_constructor(item) for item in self.items]
class ErrorPage(Renderable):
def __init__(self, message, title='Error', moji=None, status=500, **kwargs):
super().__init__(**kwargs)
if moji is None:
if status in app.config['ERROR_CUSTOM_MOJI']:
moji = app.config['ERROR_CUSTOM_MOJI'][status]
else:
moji = ''
# custom messages from config override messages in code
if status in app.config['ERROR_CUSTOM_MESSAGES']:
message = app.config['ERROR_CUSTOM_MESSAGES'][status]
self.message = message
self.title = title
self.moji = moji
self.status = status