Source code for questions.form
import json
import re
from typing import Any
from typing import Dict
from typing import Type
try:
from typing import Literal
except ImportError: # pragma: NO COVER
from typing_extensions import Literal
from .questions import Page
from .questions import PanelBlock
from .questions import PanelDynamicBlock
from .questions import Question
from .questions import QUESTION_NAMES_TO_TYPES
from .questions import Survey
from .settings import INCLUDE_KEYS
from .settings import SURVEY_JS_CDN
from .settings import SURVEY_JS_PLATFORMS
from .settings import SURVEY_JS_THEMES
from .settings import SURVEY_JS_WIDGETS
from .templates import get_form_page
from .templates import get_platform_js_resources
from .templates import get_survey_js
from .templates import get_theme_css_resources
from .utils import get_params_for_repr
from .validators import call_validator
from .validators import ValidationError
RENAMED_FIELDS = {
"type": "kind",
"isAllRowRequired": "all_rows_required",
"format": "expression_format",
"max": "max_value",
"min": "min_value",
"isRequired": "required",
}
[docs]def to_camel_case(name):
name = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", name).lower()
[docs]class Form(object):
"""
This is the base class used for creating user-defined forms. In addition to
setting up the form configuration and performing validation, it generates
the SurveyJS form JSON and keeps track of the required Javascript and CSS
resources.
:param name:
The name of the form. If empty, the class name is used.
:param action:
The URL where the form data will be posted. If empty, the
same URL for the form is used.
:param html_id:
The id for the div element that will be used to render the form.
:param theme:
The name of the base theme for the form. Default value is 'defaultV2'.
:param platform:
The JS platform to use for generating the form. Default value is 'jquery'.
:param resource_url:
The base URL for the theme resources. If provided,
Questions will expect to find all resources under this URL. If
empty, the SurveyJS CDN will be used for all resources.
:param params:
Optional list of parameters to be passed to the SurveyJS form object.
"""
questions_resource_url = SURVEY_JS_CDN
default_params = {}
def __init__(
self,
name: str = "",
action: str = "",
html_id: str = "questions_form",
theme: Literal[SURVEY_JS_THEMES] = "defaultV2",
platform: Literal[SURVEY_JS_PLATFORMS] = "jquery",
resource_url: str = None,
**params,
):
if name == "":
name = self.__class__.__name__
self.name = name
self.action = action
self.html_id = html_id
self.theme = theme
self.platform = platform
if resource_url is None:
resource_url = self.questions_resource_url
self.resource_url = resource_url
self.params = self.default_params.copy()
self.params.update(params)
self._extra_js = []
self._extra_css = []
self._form_elements = {}
def __call__(self, form_data=None):
return self.render_html(form_data=form_data)
def __repr__(self):
class_name = self.__class__.__name__
class_name = class_name[0].upper() + class_name[1:]
form_repr = f'{class_name}(\n name="{self.name}"'
if self.params:
params = get_params_for_repr(self.params)
form_repr += f",{params}"
form_repr += "\n )"
return form_repr
[docs] @classmethod
def from_json(
cls,
form_json: str,
name: str,
):
"""
Generate a Form class definition from properly formatted JSON data. The
The generated form class can then be instantiated as needed.
:param form_json:
A well formed JSON string with a SurveyJS definition.
:param name:
The name of the generated class.
:Returns:
A new Python Type that is a subclass of Form.
"""
NewForm = type(name, (cls,), {})
form_json = json.loads(form_json)
elements = form_json.items()
cls._add_type_elements(NewForm, elements)
return NewForm
@classmethod
def _add_type_elements(cls, NewForm, elements):
"""
Recursively go through the JSON elements to add all specified form
elements to the passed in Type.
"""
for name, element in elements:
if name == "pages":
for page in element:
page_title = page.get("title", "Page")
page_name = page.get("name", page_title)
page_name = page_name[0].upper() + page_name[1:]
NewPage = type(page_name, (cls,), {})
page_items = {}
page_params = {}
for key, value in page.items():
if key in ["questions", "elements", "templateElements"]:
page_items[key] = value
else:
if key in RENAMED_FIELDS:
new_key = RENAMED_FIELDS[key]
else:
new_key = to_camel_case(key)
page_params[new_key] = value
if "name" in page_params:
del page_params["name"]
if page_items:
cls._add_type_elements(NewPage, page_items.items())
form_page = FormPage(NewPage, name=page_name, **page_params)
setattr(NewForm, page_name, form_page)
elif name in ["questions", "elements", "templateElements"]:
for question_element in element:
if question_element["type"] in QUESTION_NAMES_TO_TYPES:
question_params = {}
for key, value in question_element.items():
if key in RENAMED_FIELDS:
new_key = RENAMED_FIELDS[key]
else:
new_key = to_camel_case(key)
question_params[new_key] = value
new_element = QUESTION_NAMES_TO_TYPES[question_element["type"]](
**question_params
)
setattr(NewForm, new_element.name, new_element)
elif question_element["type"] in ["panel", "paneldynamic"]:
panel_title = question_element.get("title", "Panel")
panel_name = question_element.get("name", panel_title)
panel_name = panel_name[0].upper() + panel_name[1:]
Panel = type(panel_name, (cls,), {})
dynamic = question_element["type"] == "paneldynamic"
panel_items = {}
panel_params = {}
for key, value in question_element.items():
if key in ["questions", "elements", "templateElements"]:
panel_items[key] = value
else:
if key in RENAMED_FIELDS:
new_key = RENAMED_FIELDS[key]
else:
new_key = to_camel_case(key)
panel_params[new_key] = value
if "name" in panel_params:
del panel_params["name"]
if panel_items:
cls._add_type_elements(Panel, panel_items.items())
form_panel = FormPanel(
Panel, name=panel_name, dynamic=dynamic, **panel_params
)
setattr(NewForm, panel_name, form_panel)
else:
NewForm.default_params[name] = element
def _construct_survey(self):
"""
Goes through all the form elements and creates a Survey object, which will
be used to generate the JSON for initializing SurveyJS. As a side effect,
populates extra CSS and JS resources. Also keeps a dictionary of all form
elements, used by validation and update object methods.
"""
self._extra_js = []
self._extra_css = []
self._form_elements = {}
default_page = Page(name="default")
survey = Survey(**self.params)
survey.pages.append(default_page)
self._add_elements(survey, self, top_level=True)
# get rid of duplicates
self._extra_js = list(set(self._extra_js))
self._extra_css = list(set(self._extra_css))
if self._extra_js:
self._extra_js.append(f"{self.resource_url}/{SURVEY_JS_WIDGETS}")
return survey
def _add_elements(self, survey, form, top_level=False, container_name="questions"):
"""
Method to put form elements inside a container. Needs to be recursive so that
pages and panels are properly nested.
"""
has_default_page = True
extra_js = []
extra_css = []
for element_name, element in form.__class__.__dict__.items():
if isinstance(element, (FormPage, FormPanel, Question)):
name = getattr(element, "name", "")
if name == "":
element.name = element_name
if isinstance(element, FormPage) and top_level:
if has_default_page:
survey.pages = []
has_default_page = False
page = Page(name=name, **element.params)
self._add_elements(page, element.form)
survey.pages.append(page)
elif isinstance(element, (FormPage, FormPanel)):
container = getattr(survey, container_name, None)
if container is None:
pages = survey.pages
if len(pages) > 0:
container = getattr(pages[0], container_name)
if container is None:
raise "Error in form definition: container not found."
if element.dynamic:
panel = PanelDynamicBlock(name=name, **element.params)
new_container_name = "template_elements"
else:
panel = PanelBlock(name=name, **element.params)
new_container_name = "elements"
self._add_elements(
panel, element.form, container_name=new_container_name
)
container.append(panel)
else:
self._form_elements[element.name] = element
if element.extra_js != []:
for js in element.extra_js:
url = js
if self.resource_url != SURVEY_JS_CDN:
filename = js.split("/")[-1]
url = f"{self.resource_url}/{filename}"
if (
url not in extra_js
and url not in self._extra_js
and url not in self.required_js
):
extra_js.append(url)
if element.extra_css != []:
for css in element.extra_css:
url = css
if self.resource_url != SURVEY_JS_CDN:
filename = css.split("/")[-1]
url = f"{self.resource_url}/{filename}"
if (
url not in extra_css
and url not in self._extra_css
and url not in self.required_css
):
extra_css.append(url)
if top_level:
container = survey.pages[0].questions
else:
container = getattr(survey, container_name)
container.append(element)
self._extra_js = self._extra_js + extra_js
self._extra_css = self._extra_css + extra_css
@property
def extra_js(self):
"""
Any extra JS resources required by the form's question types.
"""
self._construct_survey()
return self._extra_js
@property
def extra_css(self):
"""
Any extra CSS resources required by the form's question types.
"""
self._construct_survey()
return self._extra_css
@property
def required_js(self):
"""
Required JS resources needed to run SurveyJS on chosen platform.
"""
return get_platform_js_resources(self.platform, self.resource_url)
@property
def required_css(self):
"""
Required CSS resources needed to run SurveyJS on chosen platform.
"""
return get_theme_css_resources(self.theme, self.resource_url)
@property
def js(self):
"""
Combined JS resources for this form.
"""
return self.required_js + self.extra_js
@property
def css(self):
"""
Combined CSS resources for this form.
"""
return self.required_css + self.extra_css
[docs] def to_json(self):
"""
Convert the form to JSON, in the SurveyJS format.
:Returns:
JSON object with the form definition.
"""
survey = self._construct_survey()
return survey.json(by_alias=True, include=INCLUDE_KEYS)
[docs] def render_js(self, form_data: Dict[str, Any] = None):
"""
Generate the SurveyJS initialization code for the chosen platform.
:param form_data: answers to show on the form for each
question (for edit forms).
:Returns:
String with the generated javascript.
"""
return get_survey_js(
form_json=self.to_json(),
form_data=form_data,
html_id=self.html_id,
action=self.action,
theme=self.theme,
platform=self.platform,
)
[docs] def render_html(self, title: str = None, form_data: Dict[str, Any] = None):
"""
Render a full HTML page showing this form.
:param title:
The form title.
:param form_data:
answers to show on the form for each question (for edit forms).
:Returns:
String with the generated HTML.
"""
if title is None:
title = self.params.get("title", self.name)
if form_data is None:
form_data = {}
survey_js = self.render_js(form_data=form_data)
return get_form_page(
title=title,
html_id=self.html_id,
platform=self.platform,
survey_js=survey_js,
js_resources=self.js,
css_resources=self.css,
)
[docs] def validate(self, form_data: Dict[str, Any], set_errors: bool = False):
"""
Server side validation mimics what client side validation should do. This
means that any validation errors here are due to form data being sent from
outside the SurveyJS form, possibly by directly posting the data to the form.
Questions keeps track of the errors, even though the UI will show them anyway.
Validation returns False if at least one validator doesn't pass.
:param form_data:
A dictionary-like object with the form data to be validated.
:param set_errors:
set to :data:`True` to add an `__errors__` key to the
form data dictionary, containing the validation errors.
:Returns:
:data:`True` if the validation passes, :data:`False` otherwise.
"""
validated = True
errors = []
self._construct_survey()
for name, element in self._form_elements.items():
value = form_data.get(name)
if value is None and element.required:
errors.append({"question": name, "message": "An answer is required"})
validated = False
for validator in element.validators:
if not call_validator(validator, value, form_data):
errors.append({"question": name, "message": validator.message})
validated = False
if set_errors:
form_data["__errors__"] = errors
return validated
[docs] def update_object(self, obj: Any, form_data: Dict[str, Any]):
"""
Utility method to set an object's attributes with data obtained from a form.
This method validates the data before setting the object's attributes.
:param obj:
The object to set attributes on.
:param form_data:
A dictionary-like object with the form data to be validated.
:Raises:
questions.validators.ValidationError if validation does not pass.
"""
if self.validate(form_data):
# call to validate sets the form elements beforehand
for name in self._form_elements.keys():
setattr(obj, name, form_data[name])
else:
raise ValidationError
[docs]class FormPage(object):
"""
Represents an individual page from a multi-page form.
:param form:
A subclass of questions.Form (not an instance). The form
to be shown in its own page.
:param name:
The name of the form.
:param params:
Optional list of parameters to be passed to the SurveyJS page object.
"""
def __init__(self, form: Type[Form], name: str = "", **params):
self.form = form()
if name == "":
name = self.form.name
self.name = name
self.dynamic = False
self.params = params
def __repr__(self):
class_name = self.form.__class__.__name__
class_name = class_name[0].upper() + class_name[1:]
page_repr = f'FormPage(\n {class_name},\n name="{self.name}"'
if self.params:
params = get_params_for_repr(self.params)
page_repr += f",{params}"
page_repr += "\n )"
return page_repr
[docs]class FormPanel(object):
"""
A panel is a set of fields that go together. It can be used for visual
separation, or as a dynamically added group of fields for complex questions.
:param form:
A subclass of questions.Form (not an instance). The form
to be shown in its own page.
:param name:
The name of the form.
:param dynamic:
Set to :data:`True` if the panel will be used as a template for adding
or removing groups of questions.
:param params:
Optional list of parameters to be passed to the SurveyJS panel object.
"""
def __init__(
self,
form: Type[Form],
name: str = "",
dynamic: bool = False,
**params,
):
self.form = form()
if name == "":
name = self.form.name
self.name = name
self.dynamic = dynamic
self.params = params
def __repr__(self):
class_name = self.form.__class__.__name__
class_name = class_name[0].upper() + class_name[1:]
panel_repr = f'FormPanel(\n {class_name},\n name="{self.name}",'
panel_repr += f"\n dynamic={self.dynamic}"
if self.params:
params = get_params_for_repr(self.params)
panel_repr += f",{params}"
panel_repr += "\n )"
return panel_repr