# 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(**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 = { 'site_name': app.config['SITE_NAME'], 'user': flask.g.user, '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' 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']] return response 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/') 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) 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, items=None, id=None, **kwargs): super().__init__(**kwargs) 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): 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() 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