Source code for feretui.form

# This file is a part of the FeretUI project
#
#    Copyright (C) 2024 Jean-Sebastien SUZANNE <js.suzanne@gmail.com>
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file,You can
# obtain one at http://mozilla.org/MPL/2.0/.
"""Module feretui.form.

Addons for WTForms_. The form is usefull to create a formular in a page.

The class :class:`.FeretUIForm` are behaviour like:

* the link with the feretui translation.
* widget wrapper for renderer it with bulma class, with label in a
  **field div**.

  * :func:`.wrap_input`
  * :func:`.wrap_bool`
  * :func:`.wrap_radio`
  * :func:`.wrap_fieldset`
  * :func:`.wrap_select_multiple`
  * :func:`.no_wrap`

The wrappers, excepted :func:`._no wrap`, added behaviours in kwargs of
the **__call__** method of the field:

* readonly : Put the field in a readonly mode
* no-label : Donc display the label but keep the bulma class in th
  **field div**

Added the also the validators

* :class:`.Password`
"""

from collections.abc import Callable
from typing import TYPE_CHECKING, Any

from markupsafe import Markup
from password_validator import PasswordValidator
from polib import POFile
from wtforms.fields import (
    BooleanField,
    Field,
    FormField,
    RadioField,
    SelectFieldBase,
    SelectMultipleField,
)
from wtforms.fields.core import UnboundField
from wtforms.form import Form
from wtforms.validators import InputRequired, ValidationError
from wtforms.widgets import ListWidget
from wtforms.widgets.core import clean_key
from wtforms_components import read_only

from feretui.context import ContextProperties, cvar_feretui, cvar_request

if TYPE_CHECKING:
    from feretui.feretui import FeretUI
    from feretui.session import Session
    from feretui.translation import Translation


[docs] def wrap_input( feretui: "FeretUI", session: "Session", field: Field, **kwargs: dict, ) -> Markup: """Render input field. :param feretui: The feretui client :type feretui: :class:`feretui.feretui.FeretUI` :param session: The Session :type session: :class:`feretui.session.Session` :param field: The field to validate :type field: Field_ :return: The renderer of the widget as html. :rtype: Markup_ """ input_class = ["input"] required = False readonly = False if kwargs.get("readonly", False): input_class.append("is-static") read_only(field) readonly = True else: if field.errors: input_class.append("is-danger") for validator in field.validators: if isinstance(validator, InputRequired): if not field.errors: input_class.append("is-link") required = True c = kwargs.pop("class", "") or kwargs.pop("class_", "") kwargs["class"] = "{} {}".format(" ".join(input_class), c) return Markup.unescape( feretui.render_template( session, "feretui-input-field", label=None if kwargs.pop("nolabel", False) else field.label, widget=field.widget(field, **kwargs), required=required, readonly=readonly, description=field.description, errors=field.errors, ), )
[docs] def wrap_bool( feretui: "FeretUI", session: "Session", field: Field, **kwargs: dict, ) -> Markup: """Render boolean field. :param feretui: The feretui client :type feretui: :class:`feretui.feretui.FeretUI` :param session: The Session :type session: :class:`feretui.session.Session` :param field: The field to validate :type field: Field_ :return: The renderer of the widget as html. :rtype: Markup_ """ readonly = False if kwargs.pop("readonly", False): read_only(field) readonly = True return Markup.unescape( feretui.render_template( session, "feretui-bool-field", label=None if kwargs.pop("nolabel", False) else field.label, widget=field.widget(field, **kwargs), readonly=readonly, description=field.description, errors=field.errors, ), )
[docs] def wrap_radio( feretui: "FeretUI", session: "Session", field: "Field", **kwargs: dict, # noqa: ARG001 ) -> Markup: """Render radio field. :param feretui: The feretui client :type feretui: :class:`feretui.feretui.FeretUI` :param session: The Session :type session: :class:`feretui.session.Session` :param field: The field to validate :type field: Field_ :return: The renderer of the widget as html. :rtype: Markup_ """ vertical = kwargs.pop("vertical", True) if vertical: template_id = "feretui-radio-field-vertical" else: template_id = "feretui-radio-field-horizontal" required = False readonly = False for validator in field.validators: if isinstance(validator, InputRequired): required = True if kwargs.get("readonly"): read_only(field) kwargs["disabled"] = True readonly = True return Markup.unescape( feretui.render_template( session, template_id, label=None if kwargs.pop("nolabel", False) else field.label, field=field, required=required, readonly=readonly, options=kwargs, description=field.description, errors=field.errors, ), )
[docs] def wrap_fieldset( feretui: "FeretUI", session: "Session", field: "Field", **kwargs: dict, ) -> Markup: """Render fieldset. It is used to group fields with a `fieldset` tag. It is required for accessibility (RGAA 11.5). :param feretui: The feretui client :type feretui: :class:`feretui.feretui.FeretUI` :param session: The Session :type session: :class:`feretui.session.Session` :param field: The field to validate :type field: Field_ :return: The renderer of the widget as html. :rtype: Markup_ """ required = False readonly = False for validator in field.validators: if isinstance(validator, InputRequired): required = True if kwargs.get("readonly"): read_only(field) kwargs["disabled"] = True readonly = True return Markup.unescape( feretui.render_template( session, "feretui-fieldset", label=None if kwargs.pop("nolabel", False) else field.label, widget=field.widget(field, **kwargs), required=required, readonly=readonly, description=field.description, errors=field.errors, ), )
[docs] def wrap_select_multiple( feretui: "FeretUI", session: "Session", field: "Field", **kwargs: dict, ) -> Markup: """Render select multiple field. If the select multiple field uses a `ListWidget`, it is rendered as a fieldset (RGAA 11.5). Otherwise, it is rendered as a standard input. :param feretui: The feretui client :type feretui: :class:`feretui.feretui.FeretUI` :param session: The Session :type session: :class:`feretui.session.Session` :param field: The field to validate :type field: Field_ :return: The renderer of the widget as html. :rtype: Markup_ """ if isinstance(field.widget, ListWidget): return wrap_fieldset(feretui, session, field, **kwargs) return wrap_input(feretui, session, field, **kwargs)
[docs] def no_wrap( feretui: "FeretUI", # noqa: ARG001 session: "Session", # noqa: ARG001 field: "Field", **kwargs: dict, ) -> Markup: """Render the field widget. :param feretui: The feretui client :type feretui: :class:`feretui.feretui.FeretUI` :param session: The Session :type session: :class:`feretui.session.Session` :param field: The field to validate :type field: Field_ :return: The renderer of the widget as html. :rtype: Markup_ """ return field.widget(field, **kwargs)
[docs] def gettext( form: Form, string: str, context_suffix: str = "", ) -> str: """Translate the string.""" translation = cvar_feretui.get().translation lang = cvar_request.get().session.lang for form_cls in form.__mro__: if hasattr(form_cls, "get_context"): context = form_cls.get_context() + context_suffix res = translation.get( lang, context, string, message_as_default=False, ) if res is not None: return res return string
[docs] def get_field_translations( form_cls: Form, unbound_field: UnboundField, options: dict, callback: Callable, ) -> tuple[tuple, dict]: """Find the attribute to translate and apply the callback.""" context_suffix = f":field:{options['name']}:" args = list(unbound_field.args) kwargs = unbound_field.kwargs.copy() if args: args = list(args) args[0] = callback(form_cls, args[0], context_suffix + "label") args = tuple(args) elif unbound_field.kwargs.get("label"): kwargs["label"] = callback( form_cls, kwargs["label"], context_suffix + "label", ) else: label = options["name"].replace("_", " ").title() kwargs["label"] = callback(form_cls, label, context_suffix + "label") if kwargs.get("description"): kwargs["description"] = callback( form_cls, kwargs.get("description", ""), context_suffix + "description", ) if "choices" in kwargs: choices = kwargs.pop("choices") if callable(choices): choices = choices() if isinstance(choices, dict): choices = choices.items() new_choices = [] for choice in choices: choice = list(choice) new_choices.append(choice) choice[1] = callback( form_cls, choice[1], context_suffix + f"choice:{choice[0]}:label", ) if len(choice) == 3 and choice[2].get("description"): choice[2]["description"] = callback( form_cls, choice[2]["description"], context_suffix + f"choice:{choice[0]}:description", ) kwargs["choices"] = new_choices return args, kwargs
[docs] class FormTranslations: """Class who did the link between Form and FeretUI translations.""" def __init__(self: "FormTranslations", form: "FeretUIForm") -> None: """FormTranslations class.""" self.form = form
[docs] def gettext(self: "FormTranslations", string: str) -> str: """Return the translation.""" return gettext(self.form.__class__, string)
[docs] def ngettext( self: "FormTranslations", singular: str, plural: str, n: int, ) -> str: """Return the translation.""" if n == 1: return self.gettext(singular) return self.gettext(plural)
[docs] class FeretUIForm(Form): """Form base class. The goal is to give at the Form used by FeretUI the behaviour like: * translation * bulma renderer It is not required to used it. If the translation or the renderer is not automaticly done by FeretUI. :: class MyForm(FeretUIForm): name = StringField() """ WRAPPERS = { BooleanField: wrap_bool, FormField: wrap_fieldset, RadioField: wrap_radio, SelectFieldBase._Option: no_wrap, SelectMultipleField: wrap_select_multiple, } DEFAULT_WRAPPER = wrap_input TRANSLATED_MESSAGES = [ # From WTForms "Not a valid integer value.", "Not a valid decimal value.", "Not a valid float value.", "Not a valid datetime value.", "Not a valid date value.", "Not a valid time value.", "Not a valid week value.", "Invalid Choice: could not coerce.", "Choices cannot be None.", "Not a valid choice.", "Invalid choice(s): one or more data inputs could not be coerced.", "'%(value)s' is not a valid choice for this field.", "'%(value)s' are not valid choices for this field.", "Invalid CSRF Token.", "CSRF token missing.", "CSRF failed.", "CSRF token expired.", "Invalid field name '%s'.", "Field must be equal to %(other_name)s.", "Field must be at least %(min)d character long.", "Field must be at least %(min)d characters long.", "Field cannot be longer than %(max)d character.", "Field cannot be longer than %(max)d characters.", "Field must be exactly %(max)d character long.", "Field must be exactly %(max)d characters long.", "Field must be between %(min)d and %(max)d characters long.", "Number must be at least %(min)s.", "Number must be at most %(max)s.", "Number must be between %(min)s and %(max)s.", "This field is required.", "Invalid input.", "Invalid email address.", "Invalid IP address.", "Invalid Mac address.", "Invalid URL.", "Invalid UUID.", "Invalid value, must be one of: %(values)s.", "Invalid value, can't be any of: %(values)s.", "This field cannot be edited", "This field is disabled and cannot have a value", # From WTForms Components "Not a valid time.", "Not a valid decimal range value", "Not a valid float range value", "Not a valid int range value", "Not a valid date range value", "Not a valid datetime range value", "Not a valid choice", "Not a valid color.", "Invalid choice(s): one or more data inputs could not be coerced", "'%(value)s' is not a valid choice for this field", "Date must be greater than %(min)s.", "Date must be less than %(max)s.", "Date must be between %(min)s and %(max)s.", "Time must be greater than %(min)s.", "Time must be less than %(max)s.", "Time must be between %(min)s and %(max)s.", "This field contains invalid JSON", ]
[docs] @classmethod def register_translation(cls: "FeretUIForm", message: str) -> str: """Register a translation come from validator. Some text is defined in the validator or WTForms_ addons. They can not be export easily. This register give the possibility for the devloper to define their. """ if message not in FeretUIForm.TRANSLATED_MESSAGES: FeretUIForm.TRANSLATED_MESSAGES.append(message) return message
[docs] @classmethod def get_context(cls: "FeretUIForm") -> str: """Return the context for the translation.""" return f"form:{cls.__module__}:{cls.__name__}"
[docs] @classmethod def export_catalog( cls: "FeretUIForm", translation: "Translation", po: POFile, ) -> None: """Export the Form translation in the catalog. :param translation: The translation instance to add also inside it. :type translation: :class:`.Translation` :param po: The catalog instance :type po: PoFile_ """ def callback(form_cls: Form, string: str, context_suffix: str) -> str: context = form_cls.get_context() + context_suffix po.append(translation.define(context, string)) return string for attr in dir(cls): field_cls = getattr(cls, attr) if not isinstance(field_cls, UnboundField): continue get_field_translations(cls, field_cls, {"name": attr}, callback)
[docs] class Meta(ContextProperties): """Meta class. Added * Translation * Bulma render """
[docs] def bind_field( self: Any, form: Form, unbound_field: UnboundField, options: dict, ) -> Field: """Bind the field to the form. Added translation for the field """ args, kwargs = get_field_translations( form.__class__, unbound_field, options, gettext, ) return UnboundField( unbound_field.field_class, *args, **kwargs, ).bind(form=form, **options)
[docs] def render_field(self: Any, field: Field, render_kw: dict) -> Markup: """Render the field. :param field: The field to render :type field: Field_ :return: The renderer of the widget as html. :rtype: Markup_ """ render_kw = {clean_key(k): v for k, v in render_kw.items()} other_kw = getattr(field, "render_kw", None) if other_kw is not None: other_kw = {clean_key(k): v for k, v in other_kw.items()} render_kw = dict(other_kw, **render_kw) wrapper = FeretUIForm.WRAPPERS.get( field.__class__, FeretUIForm.DEFAULT_WRAPPER, ) return wrapper( self.feretui, self.request.session, field, **render_kw, )
[docs] def get_translations( self: Any, form: "FeretUIForm", ) -> FormTranslations: """Return Translation class. :param form: The form instance :type form: :class:`.FeretUIForm` :return: The translation class to link feretui translation. :rtype: :class:`.FormTranslations` """ return FormTranslations(form)
PasswordInvalid = FeretUIForm.register_translation( "The password should have {msg}.", ) PasswordMinSize = FeretUIForm.register_translation( "more than {min_size} caractere", ) PasswordMaxSize = FeretUIForm.register_translation( "less than {max_size} caractere", ) PasswordWithLowerCase = FeretUIForm.register_translation("with lowercase") PasswordWithoutLowerCase = FeretUIForm.register_translation("without lowercase") PasswordWithUpperCase = FeretUIForm.register_translation("with uppercase") PasswordWithoutUpperCase = FeretUIForm.register_translation("without uppercase") PasswordWithLetters = FeretUIForm.register_translation("with letters") PasswordWithoutLetters = FeretUIForm.register_translation("without letters") PasswordWithDigits = FeretUIForm.register_translation("with digits") PasswordWithoutDigits = FeretUIForm.register_translation("without digits") PasswordWithSymbols = FeretUIForm.register_translation("with symbols") PasswordWithoutSymbols = FeretUIForm.register_translation("without symbols") PasswordWithSpaces = FeretUIForm.register_translation("with spaces") PasswordWithoutSpaces = FeretUIForm.register_translation("without spaces")
[docs] class Password(InputRequired): """Password validator. It is a generic validator for WTForms_. It is based on the library `password-validator <https://github.com/tarunbatra/password-validator-python>`_. :: class MyForm(Form): password = PasswordField(validators=[Password()]) :param min_size: The minimal size of the password. If None then no minimal size. :type min_size: int :param max_size: The maximal size. If None then no maximal size. :type max_size: int :param has_lowercase: If True the password must have one or more lowercase. If False the password must not have any lowercase If None no rule on the lowercase :type has_lowercase: bool :param has_uppercase: If True the password must have one or more uppercase. If False the password must not have any uppercase If None no rule on the uppercase :type has_uppercase: bool :param has_letters: If True the password must have one or more letters. If False the password must not have any letters If None no rule on the letters :type has_letters: bool :param has_digits: If True the password must have one or more digits. If False the password must not have any digits If None no rule on the digits :type has_digits: bool :param has_symbols: If True the password must have one or more symbols. If False the password must not have any symbols If None no rule on the symbols :type has_symbols: bool :param has_spaces: If True the password must have one or more spaces. If False the password must not have any spaces If None no rule on the spaces :type has_spaces: bool """ def __init__( self: "Password", min_size: int = 12, max_size: int = None, has_lowercase: bool = True, has_uppercase: bool = True, has_letters: bool = True, has_digits: bool = True, has_symbols: bool = True, has_spaces: bool = False, ) -> None: """Password class.""" self.schema = PasswordValidator() self.waiting = [] if min_size: self.schema.min(min_size) self.waiting.append( ( PasswordMinSize, {"min_size": min_size}, ), ) if max_size: self.schema.min(max_size) self.waiting.append( ( PasswordMaxSize, {"max_size": max_size}, ), ) for has, attr, true_msg, false_msg in ( ( has_lowercase, "lowercase", PasswordWithLowerCase, PasswordWithoutLowerCase, ), ( has_uppercase, "uppercase", PasswordWithUpperCase, PasswordWithoutUpperCase, ), ( has_letters, "letters", PasswordWithLetters, PasswordWithoutLetters, ), (has_digits, "digits", PasswordWithDigits, PasswordWithoutDigits), ( has_symbols, "symbols", PasswordWithSymbols, PasswordWithoutSymbols, ), (has_spaces, "spaces", PasswordWithSpaces, PasswordWithoutSpaces), ): if has is True: getattr(self.schema.has(), attr)() self.waiting.append((true_msg, {})) elif has is False: getattr(self.schema.has().no(), attr)() self.waiting.append((false_msg, {})) def __call__( self: "PasswordWithoutSpaces", form: Form, # noqa: ARG002, U100 field: Field, ) -> None: """Validate if the field is a valid password. :param form: A Form :type form: Form_ :param field: The field to validate :type field: Field_ :exception': ValidationError_ """ if not self.schema.validate(field.data): msg = field.gettext(PasswordInvalid).format( msg=", ".join( field.gettext(x[0]).format(**x[1]) for x in self.waiting ), ) raise ValidationError(msg)