Source code for form.display

""" Contains renderers to display form fields. """

import humanize
import re

from markupsafe import escape, Markup
from onegov.core.markdown import render_untrusted_markdown
from onegov.form import log
from translationstring import TranslationString
from wtforms.widgets.core import html_params

from typing import TypeVar, TYPE_CHECKING
if TYPE_CHECKING:
    from abc import abstractmethod
    from collections.abc import Callable
    from wtforms import Field

_R = TypeVar('_R', bound='BaseRenderer')

__all__ = ('render_field',)


class Registry:
    """ Keeps track of all the renderers and the types they are registered for,
    making sure each renderer is only instantiated once.

    """
    renderer_map: dict[str, 'BaseRenderer']

    def __init__(self) -> None:
        self.renderer_map = {}

    def register_for(self, *types: str) -> 'Callable[[type[_R]], type[_R]]':
        """ Decorator to register a renderer. """
        def wrapper(renderer: type[_R]) -> type[_R]:
            instance = renderer()

            for type in types:
                self.renderer_map[type] = instance

            return renderer
        return wrapper

    def render(self, field: 'Field') -> Markup:
        """ Renders the given field with the correct renderer. """
        if not field.data:
            return Markup('')

        renderer = self.renderer_map.get(field.type)

        if renderer is None:
            log.warning(f'No renderer found for {field.type}')
            return Markup('')
        else:
            return renderer(field)


registry = Registry()

# public interface
[docs] render_field = registry.render
class BaseRenderer: """ Provides utility functions for all renderers. """ if TYPE_CHECKING: # forward declare that Renderer needs to be callable @abstractmethod def __call__(self, field: Field) -> Markup: ... def escape(self, text: str) -> Markup: return escape(text) def translate(self, field: 'Field', text: str) -> str: if isinstance(text, TranslationString): return field.gettext(text) return text @registry.register_for( 'StringField', 'TextAreaField', ) class StringFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: if field.render_kw: if field.render_kw.get('data-editor') == 'markdown': return render_untrusted_markdown(field.data) return self.escape(field.data or '').replace('\n', Markup('<br>')) @registry.register_for('PasswordField') class PasswordFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: return Markup('*' * len(field.data)) # noqa: RUF035 @registry.register_for('EmailField') class EmailFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: params = {'href': f'mailto:{field.data}'} return Markup( # noqa: RUF035 f'<a {html_params(**params)}>{{email}}</a>' ).format(email=field.data) @registry.register_for('URLField') class URLFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: params = {'href': field.data} return Markup( # noqa: RUF035 f'<a {html_params(**params)}>{{url}}</a>' ).format(url=field.data) @registry.register_for('VideoURLField') class VideoURLFieldRenderer(BaseRenderer): """ Renders a video url. Embeds the video if in case of vimeo or youtube otherwise just displays the url as a link. """ video_template = Markup(""" <div class="video"> <div class="videowrapper"> <iframe allow="fullscreen" frameborder="0" src="{url}" sandbox="allow-scripts allow-same-origin allow-presentation" referrerpolicy="no-referrer"></iframe> </div> </div> """) url_template = Markup('<a href="{url}">{url}</a>') def __call__(self, field: 'Field') -> Markup: url = None data = field.data # youtube if any(x in data for x in ['youtube', 'youtu.be']): url = self.ensure_youtube_embedded_url(data) # vimeo if any(x in data for x in ['vimeo']): url = self.ensure_vimeo_embedded_url(data) if url: return self.video_template.format(url=url) # for non-vimeo and non-youtube sources we just display the url return self.url_template.format(url=data) @staticmethod def ensure_youtube_embedded_url(url: str) -> str | None: pattern = re.compile( r'(?:https?://)?(?:www\.)?(?:m\.)?' r'(?:youtube|youtu|youtube-nocookie)\.(?:com|be)/' r'(?:watch\?v=|embed/|v/|.+\?v=)?([^&=%\?]{11})' ) match = re.match(pattern, url) if match: video_id = match.group(1) return (f'https://www.youtube.com/embed/' f'{video_id}?rel=0&autoplay=0&mute=1') return None @staticmethod def ensure_vimeo_embedded_url(url: str) -> str | None: pattern = re.compile( r'(?:https?://)?(?:www\.)?' r'(?:player\.)?vimeo\.com\/(?:channels\/(' r'?:\w+\/)?|groups\/(?:[^\/]*)\/videos\/|video\/|)(\d+)(' r'?:|\/\?)') match = re.match(pattern, url) if match: video_id = match.group(1) return (f'https://player.vimeo.com/video/{video_id}?muted=1' f'&autoplay=0') return None @registry.register_for('DateField') class DateFieldRenderer(BaseRenderer): # XXX we assume German here currently - should this change we'd have # to add a date format to the request and pass it here - which should # be doable with little work (not necessary for now) date_format = '%d.%m.%Y' def __call__(self, field: 'Field') -> Markup: return self.escape(field.data.strftime(self.date_format)) @registry.register_for('DateTimeLocalField') class DateTimeLocalFieldRenderer(DateFieldRenderer): date_format = '%d.%m.%Y %H:%M' @registry.register_for('TimezoneDateTimeField') class TimezoneDateTimeFieldRenderer(DateFieldRenderer): date_format = '%d.%m.%Y %H:%M %Z' @registry.register_for('TimeField') class TimeFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: return Markup( # noqa: RUF035 f'{field.data.hour:02d}:{field.data.minute:02d}' ) @registry.register_for('UploadField') class UploadFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: return Markup('{filename} ({size})').format( filename=field.data['filename'], size=humanize.naturalsize(field.data['size']) ) @registry.register_for('UploadMultipleField') class UploadMultipleFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: return Markup('<br>').join( Markup('<i class="far fa-file-pdf"></i> {filename} ({' 'size})').format( filename=data['filename'], size=humanize.naturalsize(data['size']) ) for data in field.data if data ) @registry.register_for('RadioField') class RadioFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: choices = dict(field.choices) # type:ignore[attr-defined] return self.escape('✓ ' + self.translate( field, choices.get(field.data, '?') )) @registry.register_for('MultiCheckboxField') class MultiCheckboxFieldRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: choices = {value: f'? ({value})' for value in field.data} choices.update(field.choices) # type:ignore[attr-defined] return Markup('<br>').join( f'✓ {self.translate(field, choices[value])}' for value in field.data ) @registry.register_for('CSRFTokenField', 'HiddenField') class NullRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: return Markup('') @registry.register_for('DecimalField') class DecimalRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: return Markup(f'{field.data:.2f}') # noqa: RUF035 @registry.register_for('IntegerField') class IntegerRenderer(BaseRenderer): def __call__(self, field: 'Field') -> Markup: return Markup(f'{int(field.data)}') # noqa: RUF035