pdnew/rendering.py

466 lines
15 KiB
Python
Raw Permalink Normal View History

2024-04-12 19:22:00 +00:00
# builtins
import math
import os
2024-04-12 19:22:00 +00:00
import re
# third-party
import markupsafe
2024-04-12 19:22:00 +00:00
import werkzeug
import flask
import peewee # for type checking
2024-04-12 19:22:00 +00:00
# internals
from application import app
import util
def page(**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):
params = {
2024-05-20 02:10:17 +00:00
'site_name': app.config['SITE_NAME'],
'user': flask.g.user,
2024-05-20 02:10:17 +00:00
'tls_cipher': flask.g.tls_cipher,
'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'
2024-04-12 19:22:00 +00:00
if 'layout' in kwargs:
# explicitly chosen layout
template_candidates = [
f'{app.config["THEME"]}/templates/layout/{params["layout"]}.{params["format"]}',
2024-04-12 19:22:00 +00:00
]
2024-04-12 19:22:00 +00:00
if app.config['THEME'] != 'default':
template_candidates.append(f'default/templates/layout/{params["layout"]}.{params["format"]}')
2024-04-12 19:22:00 +00:00
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"]}',
2024-04-12 19:22:00 +00:00
]
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"]}')
2024-04-12 19:22:00 +00:00
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
2024-04-12 19:22:00 +00:00
for menu_name in app.config['MENUS']:
2024-04-12 19:22:00 +00:00
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)
2024-04-12 19:22:00 +00:00
except Exception as e:
2024-04-12 19:22:00 +00:00
if app.debug:
raise
2024-04-12 19:22:00 +00:00
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)}")
2024-04-12 19:22:00 +00:00
if 'mode' not in params:
params['mode'] = 'full'
2024-04-12 19:22:00 +00:00
if 'title' not in params: # means it was passed directly in **template_params
2024-04-12 19:22:00 +00:00
if type(content) != str and\
hasattr(content, 'title') and\
content.title is not None:
params['title'] = content.title
else:
2024-04-23 08:17:44 +00:00
params['title'] = None
2024-04-12 19:22:00 +00:00
if 'title_show' not in params:
params['title_show'] = True
status = content.status if isinstance(content, ErrorPage) else 200
2024-04-12 19:22:00 +00:00
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']]
return response
2024-04-12 19:22:00 +00:00
wrapper.__name__ = f.__name__ # avoid name collisions in routing data
return wrapper
return decorator
@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):
return flask.send_from_directory(directory_candidate, filename)
flask.abort(404)
2024-04-12 19:22:00 +00:00
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):
2024-04-12 19:22:00 +00:00
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}',
2024-04-12 19:22:00 +00:00
]
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}',
2024-04-12 19:22:00 +00:00
]
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])
2024-04-12 19:22:00 +00:00
@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
2024-04-12 19:22:00 +00:00
return wrapped(**kwargs)
def access(self, mode='full'):
return True
def render(self, mode='full', format='html'):
2024-04-12 19:22:00 +00:00
return markupsafe.Markup(flask.render_template(
self.template_candidates(mode, format),
2024-04-12 19:22:00 +00:00
content=self,
mode=mode,
format=format,
))
2024-04-12 19:22:00 +00:00
class Menu(Renderable):
def __init__(self, items=None, id=None, **kwargs):
super().__init__(**kwargs)
2024-04-12 19:22:00 +00:00
self.items = []
self.id = id
if items:
for item in items:
self.append(**item)
def append(self, url=None, label=None, endpoint=None, params=None, classes=None):
2024-04-12 19:22:00 +00:00
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.
2024-04-12 19:22:00 +00:00
if flask.request.path.startswith(url):
state = 'trail'
else:
state = 'inactive'
2024-04-12 19:22:00 +00:00
self.items.append({
'url': url,
'endpoint': endpoint,
'state': state,
'label': label,
'classes': classes
2024-04-12 19:22:00 +00:00
})
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()
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'
)
2024-04-12 19:22:00 +00:00
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)
2024-04-12 19:22:00 +00:00
self.items = items
self.title = title
self.mode = mode
2024-04-12 19:22:00 +00:00
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]
2024-04-12 19:22:00 +00:00
class ErrorPage(Renderable):
def __init__(self, message, title='Error', moji=None, status=500, **kwargs):
super().__init__(**kwargs)
2024-04-12 19:22:00 +00:00
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