522 lines
16 KiB
Python
522 lines
16 KiB
Python
# 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
|