diff --git a/HISTORY.rst b/HISTORY.rst index daca7cd..80b8459 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,7 +2,7 @@ History ======= -0.1.0 (2020-08-15) +0.1.0 (2020-09-28) ------------------ -* First release on PyPI. +* First (alpha) release on PyPI. diff --git a/Makefile b/Makefile index 0fb87e4..ea46214 100644 --- a/Makefile +++ b/Makefile @@ -68,9 +68,9 @@ coverage: ## check code coverage quickly with the default Python docs: ## generate Sphinx HTML documentation, including API docs rm -f docs/questions.rst rm -f docs/modules.rst - sphinx-apidoc -o docs/ questions + sphinx-apidoc -e -o docs/ questions $(MAKE) -C docs clean - $(MAKE) -C docs html + $(MAKE) -b spelling -C docs html $(BROWSER) docs/_build/html/index.html servedocs: docs ## compile the docs watching for changes diff --git a/docs/_static/README.txt b/docs/_static/README.txt new file mode 100644 index 0000000..548d720 --- /dev/null +++ b/docs/_static/README.txt @@ -0,0 +1 @@ +Include static resources here. diff --git a/docs/conf.py b/docs/conf.py index 0c2afb5..fc8ed59 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,12 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx_autodoc_typehints', + 'sphinxcontrib.spelling', +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -89,7 +94,13 @@ # theme further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +html_theme_options = { + "page_width": "1200px", + "github_user": "cguardia", + "github_repo": "questions", + "github_banner": "true", + "github_button": "false", +} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -159,4 +170,8 @@ ] +# -- Options for spell checking ---------------------------------------- +# File containing list of words known to be spelled correctly, but not in +# the dictionary. +spelling_word_list_filename = 'spelling_word_list.txt' diff --git a/docs/index.rst b/docs/index.rst index d5315aa..e84286c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,13 @@ Welcome to Questions's documentation! ====================================== +Questions is a Python form library that uses the power of SurveyJS_ for the UI. +The philosophy behind Questions is that modern form rendering usually requires +integrating some complex Javascript widgets anyway, so why not skip the markup +generation completely? + +.. _SurveyJS: https://surveyjs.io + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/docs/questions.cli.rst b/docs/questions.cli.rst new file mode 100644 index 0000000..cc4e039 --- /dev/null +++ b/docs/questions.cli.rst @@ -0,0 +1,7 @@ +questions.cli module +==================== + +.. automodule:: questions.cli + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/questions.form.rst b/docs/questions.form.rst new file mode 100644 index 0000000..c6b2c2f --- /dev/null +++ b/docs/questions.form.rst @@ -0,0 +1,7 @@ +questions.form module +===================== + +.. automodule:: questions.form + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/questions.questions.rst b/docs/questions.questions.rst new file mode 100644 index 0000000..84ddd19 --- /dev/null +++ b/docs/questions.questions.rst @@ -0,0 +1,7 @@ +questions.questions module +========================== + +.. automodule:: questions.questions + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/questions.rst b/docs/questions.rst index a3f0acb..9f68863 100644 --- a/docs/questions.rst +++ b/docs/questions.rst @@ -4,59 +4,20 @@ questions package Submodules ---------- -questions.cli module --------------------- - -.. automodule:: questions.cli - :members: - :undoc-members: - :show-inheritance: - -questions.form module ---------------------- - -.. automodule:: questions.form - :members: - :undoc-members: - :show-inheritance: - -questions.questions module --------------------------- - -.. automodule:: questions.questions - :members: - :undoc-members: - :show-inheritance: - -questions.settings module -------------------------- - -.. automodule:: questions.settings - :members: - :undoc-members: - :show-inheritance: - -questions.templates module --------------------------- - -.. automodule:: questions.templates - :members: - :undoc-members: - :show-inheritance: - -questions.validators module ---------------------------- - -.. automodule:: questions.validators - :members: - :undoc-members: - :show-inheritance: +.. toctree:: + :maxdepth: 4 + questions.cli + questions.form + questions.questions + questions.settings + questions.templates + questions.validators Module contents --------------- .. automodule:: questions - :members: - :undoc-members: - :show-inheritance: + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/questions.settings.rst b/docs/questions.settings.rst new file mode 100644 index 0000000..75153be --- /dev/null +++ b/docs/questions.settings.rst @@ -0,0 +1,7 @@ +questions.settings module +========================= + +.. automodule:: questions.settings + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/questions.templates.rst b/docs/questions.templates.rst new file mode 100644 index 0000000..37cd79d --- /dev/null +++ b/docs/questions.templates.rst @@ -0,0 +1,7 @@ +questions.templates module +========================== + +.. automodule:: questions.templates + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/questions.validators.rst b/docs/questions.validators.rst new file mode 100644 index 0000000..309d2f0 --- /dev/null +++ b/docs/questions.validators.rst @@ -0,0 +1,7 @@ +questions.validators module +=========================== + +.. automodule:: questions.validators + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/spelling_word_list.txt b/docs/spelling_word_list.txt new file mode 100644 index 0000000..c06ba61 --- /dev/null +++ b/docs/spelling_word_list.txt @@ -0,0 +1,18 @@ +cli +darkblue +darkrose +docstring +docstrings +dropdown +dropdowns +javascript +JQuery +jquery +ko +repo +submodules +validator +validators +virtualenvwrapper +vue +winterstone diff --git a/docs/usage.rst b/docs/usage.rst index 9320adf..03c74f6 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -1,7 +1,330 @@ +=========== +Quick Start +=========== + +Questions forms are basically class definitions, where each question is a form +attribute:: + + from questions import Form + from questions import DropdownQuestion + from questions import TextQuestion + + + class PreferencesForm(Form): + email = TextQuestion(input_type="email") + email_format = DropdownQuestion(choices=["PDF", "HTML", "Plain Text"]) + +There are many kinds of :doc:`questions `, for different kinds of +input types. Most questions have several possible parameters, but most of them +are optional, so the question can be defined with just the parameters that are +needed for each case, as in the example above. + +To use a form, an instance has to be created. The Form constructor also +accepts various parameters, but like the Question parameters, they are only +used when they are needed:: + + prefs = PreferencesForm() + +Displaying the forms +==================== + +Questions generates a SurveyJS form, which requires a few Javascript and CSS +resources to work. The simplest way to display a form is to use the SurveyJS +CDN to serve all these resources, which requires no work and is the default +form display mode. A full HTML page with all required resources and Javascript +code can be generated like this:: + + prefs = PreferencesForm() + html = prefs.render_html() + +This should be just enough for many small applications, but most applications +will need to combine the form code with their own layout and resources. Again, +this can be accomplished using the CDN or not. Here's a simple example of how +to integrate a Questions form into an existing web application using the CDN: + +Python view code +---------------- + +To render a form in a template, the Python view code must pass the form +instance to the template. In a generic Python web framework, this would look +like the following:: + + def preferences_form(): + prefs = PreferencesForm() + return {"form": prefs} + +HTML template +------------- + +In the template, using Jinja_ the form would be used like this: + +.. code-block:: html+jinja + + +
+ + + {% for script in form.js %} + + {% endfor %} + + + + + + + {% for stylesheet in form.css %} + + {% endfor %} + + + + + + + + +What is needed to make the form work is to insert all the required JS and CSS +resources, followed by the form definition and initialization script. The only +thing needed on the HTML side, is the ``div`` where the form will be inserted. +It *must* use the `questions_form` id, unless a different id is passed in +when creating the form, by using the ``html_id`` parameter. + +.. _Jinja: https://jinja.palletsprojects.com/ + +Displaying forms without using the CDN +-------------------------------------- + +When not using the SurveyJS CDN, there are two ways to display the forms. The +first one requires downloading or installing all the resources under the same +directory, and passing the URL for this directory to the form constructor:: + + prefs = PreferencesForm(resource_url="/static_resources") + +This is the easiest way to do it, and requires no changes to the HTML above. + +The other way to do this also requires downloading or installing all the +required resources, but instead of using the `resource_url` parameter, remove +the JS and CSS loops from the HTML template, and in their place put in the +list of locally installed resources. + +Panels +====== + +A panel is a container of form controls that are presented as a group. It's +like a question with multiple parts. To create a panel, a separate form has to +be defined, and it is then passed in to the panel constructor:: + + from questions import Form + from questions import FormPanel + from questions import BooleanQuestion + from questions import DropdownQuestion + from questions import TextQuestion + + + class PreferencesForm(Form): + email = TextQuestion(input_type="email") + email_format = DropdownQuestion(choices=["PDF", "HTML", "Plain Text"]) + + + class ProfileForm(Form): + receive_newsletter = BooleanQuestion( + title="Do you wish to receive our newsletter?", + is_required=True, + ) + newsletter_panel = FormPanel( + PreferencesForm, + title="Newsletter Preferences", + visible_if="{receive_newsletter} == True", + ) + +In the example above, ``PreferencesForm`` will act as a panel inside +``ProfileForm``. Note that that the ``FormPanel`` constructor takes the form +definition (the class) as the parameter, *not* an instance of the form. The +use of the ``visible_if`` condition makes sure the newsletter preferences +panel will only be shown if the user elects to receive the newsletter. + +It is possible to have a panel inside a panel, and even more nested panels if +desired. However, be aware that multiple levels of nesting can be confusing +for the user and require more complex code to get at the form data later. + +Dynamic panels +============== + +A dynamic panel is also a container for questions with multiple parts, but it +has the added feature that copies of it can be dynamically added and removed +from a form. In this way a user can add two or more related panels, like for +example relatives, social media accounts, or previous illnesses. It is +defined in the same way as a regular panel, except the ``dynamic`` parameter +is set to true:: + + from questions import Form + from questions import FormPanel + from questions import BooleanQuestion + from questions import DropdownQuestion + from questions import TextQuestion + + + class SocialMediaForm(Form): + service = DropdownQuestion(choices=["Twitter", "Instagram", "Snapchat"]) + account = TextQuestion() + + + class ProfileForm(Form): + social_media = FormPanel( + SocialMediaForm, + title="Social Media Accounts", + dynamic=True, + panel_count=2, + ) + +The above form will allow the user to add any number of social accounts. Pay +attention to the ``panel_count`` parameter, which signals that two panels will +be active when the form is first rendered. + +Pages ===== -Usage -===== -To use Questions in a project:: +Questions also allows the user to easily create multiple page forms. A page +form is like a panel that will be presented on its own page. When a form has +more than one page, Questions will add page navigation controls to move back +and forth between the pages. The final page will show a `complete` button:: + + from questions import Form + from questions import FormPage + from questions import TextQuestion + from questions import DropdownQuestion + + + class PageOne(Form): + name = TextQuestion() + email = TextQuestion(input_type="email", is_required="True") + + + class PageTwo(Form): + country = DropdownQuestion(choices_by_url={"value_name": "name", + "url": "https://restcountries.eu/rest/v2/all"}) + birthdate = TextQuestion(input_type="date") + + + class Profile(Form): + page_one = FormPage(PageOne, title="Identification Information") + page_two = FormPage(PageTwo, title="Additional Information") + +Although Questions will not complain if a page is added to another page, the +nested page will be treated like a panel, not a page. + +Accessing form data +=================== + +Once a questions form is submitted, the data will be posted to the page URL. To +get the form data, simply use you web framework's way of accessing JSON data. +For example, in Flask:: + + @app.route("/", methods=("POST",)) + def post(): + form_data = request.get_json() + +The form data is returned in a dictionary format, a key for each form field, +regardless of the page and panel structure of the form. A dynamic panel will +be represented as a list of dictionaries. For example:: + + { + 'name': 'John Smith', + 'email': 'smith@smith.me', + 'birthdate': '1980-05-08', + 'country': 'US' + } + +Since the data is returned as a single dictionary, it's not allowed to use +the same name for more than one field, even if the form has multiple pages. + +Edit Forms +========== + +An edit form is a form that shows predetermined values at render time. The +user can then change only the desired values. This would be used to edit +objects stored in a database, for example. To set up an edit form in +Questions, simply pass in a dictionary with the data to the form rendering +method, using the ``form_data`` parameter:: + + form = Profile() + + profile_data = { + 'name': 'John Smith', + 'email': 'smith@smith.me', + 'birthdate': '1980-05-08', + 'country': 'US' + } + + questions_js = form.render_js(form_data=profile_data) + +Here we are using a simple dictionary to set up the data, but of course the +usual thing to do for an edit form would be to get the data from a database. + +Validation +========== + +Form questions can have one or more validators assigned. The form data will be +validated on the front end, and the form cannot be sent unless they all pass. +Still, a user or bot could submit a Questions form directly to the Python +view, bypassing the validation. This is why questions includes mirror +validators that perform the same checks as the SurveyJS front end on the +server side. + +SurveyJS has five standard validators: + + - `Numeric`. Fails if the question answer is not a number, or if an entered + number is outside the ``min_value`` and ``max_value`` range. + - `Text`. Fails the entered text length is outside the ``min_length`` and + ``max_length`` range. + - `Expression`. Fails when ``expression`` returns false. + - `Regex`. Fails if the entered value does not fit a regular expression + (``regex``). + - `Email`. Fails if the entered value is not a valid e-mail. + +Questions allows the use of any of this validators, using its corresponding +validator classes:: + + from questions import Form + from questions import DropdownQuestion + from questions import TextQuestion + from questions import ExpressionValidator + from questions import NumericValidator + + class ValidatedForm(Form): + age = TextQuestion( + input_type="number", + validators=[ + NumericValidator( + max_value=130, + message="We sincerely doubt that is your age", + ) + ] + ) + tickets = DropdownQuestion( + choices=[1, 2, 3, 4, 5], + validators = [ + ExpressionValidator( + expression="{age} > 18 or {tickets} < 2", + message="Minors can only buy one ticket", + ) + ] + ) + +Notice that the expression validator allows referring to any other question +on the form, using the question name in brackets. This permits complex +validations. + +As mentioned above, validation will be performed in the front end, but it is +recommended to call the mirroring server side validation anyway, for safety. +To do that simply call the ``validate`` method on the form data:: - import questions + @app.route("/", methods=("POST",)) + def post(): + form.ValidatedForm() + form_data = request.get_json() + if form.validate(form_data): + # validation successful. Save data or something. diff --git a/questions/__init__.py b/questions/__init__.py index c7eb887..cd5dac7 100644 --- a/questions/__init__.py +++ b/questions/__init__.py @@ -16,8 +16,10 @@ from .questions import CKEditorQuestion from .questions import CommentQuestion from .questions import DropdownQuestion +from .questions import EmailValidator from .questions import EmotionsRatingQuestion from .questions import ExpressionBlock +from .questions import ExpressionValidator from .questions import FileQuestion from .questions import HtmlBlock from .questions import ImageBlock @@ -29,13 +31,16 @@ from .questions import MicrophoneQuestion from .questions import MultipleTextQuestion from .questions import NoUISliderQuestion +from .questions import NumericValidator from .questions import RadioGroupQuestion from .questions import RatingQuestion +from .questions import RegexValidator from .questions import Select2Question from .questions import SignaturePadQuestion from .questions import SortableJSQuestion from .questions import TagBoxQuestion from .questions import TextQuestion +from .questions import TextValidator __all__ = [ @@ -47,8 +52,10 @@ "CKEditorQuestion", "CommentQuestion", "DropdownQuestion", + "EmailValidator", "EmotionsRatingQuestion", "ExpressionBlock", + "ExpressionValidator", "FileQuestion", "Form", "FormPage", @@ -63,11 +70,14 @@ "MicrophoneQuestion", "MultipleTextQuestion", "NoUISliderQuestion", + "NumericValidator", "RadioGroupQuestion", "RatingQuestion", + "RegexValidator", "Select2Question", "SignaturePadQuestion", "SortableJSQuestion", "TagBoxQuestion", "TextQuestion", + "TextValidator", ] diff --git a/questions/form.py b/questions/form.py index 47d520a..a0a549f 100644 --- a/questions/form.py +++ b/questions/form.py @@ -27,13 +27,31 @@ class Form(object): 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 'default'. + :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. """ def __init__( self, name: str = "", - method: Literal[("GET", "POST")] = "POST", action: str = "", + html_id: str = "questions_form", theme: Literal[SURVEY_JS_THEMES] = "default", platform: Literal[SURVEY_JS_PLATFORMS] = "jquery", resource_url: str = SURVEY_JS_CDN, @@ -42,9 +60,9 @@ def __init__( if name == "": name = self.__class__.__name__ self.name = name - self.theme = theme - self.method = method self.action = action + self.html_id = html_id + self.theme = theme self.platform = platform self.resource_url = resource_url self.params = params @@ -52,8 +70,8 @@ def __init__( self._extra_css = [] self._form_elements = {} - def __call__(self): - return self.render_html() + def __call__(self, form_data=None): + return self.render_html(form_data=form_data) def _construct_survey(self): self._extra_js = [] @@ -121,54 +139,114 @@ def _add_elements(self, survey, form, top_level=False, container_name="questions @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 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) def render_js(self, form_data=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( - self.to_json(), form_data, self.action, self.theme, self.platform + self.to_json(), + form_data, + self.html_id, + self.action, + self.theme, + self.platform, ) def render_html(self, title=None, form_data=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, self.platform, survey_js, self.js, self.css) + return get_form_page( + title, self.html_id, self.platform, survey_js, self.js, self.css + ) - def validate(self, form_data): + def validate(self, form_data, set_errors=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 = [] @@ -178,19 +256,26 @@ def validate(self, form_data): if value is None and element.is_required: errors.append({"question": name, "message": "An answer is required"}) validated = False - for validator_data in element.validators: - if not call_validator(validator_data, value, form_data): - errors.append( - {"question": name, "message": validator_data["message"]} - ) + for validator in element.validators: + if not call_validator(validator, value, form_data): + errors.append({"question": name, "message": validator.message}) validated = False - form_data["__errors__"] = errors + if set_errors: + form_data["__errors__"] = errors return validated 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): @@ -205,7 +290,18 @@ def __init__(self, form: Type[Form], name: str = "", **params): class FormPanel(object): """ A panel is a set of fields that go together. It can be used for visual - separation, or as a dinamically added group of fields for complex questions. + 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__( diff --git a/questions/questions.py b/questions/questions.py index ef11ec4..8cdcbb9 100644 --- a/questions/questions.py +++ b/questions/questions.py @@ -40,6 +40,38 @@ def fix_name(cls, name): alias_generator = fix_name +class Validator(Base): + kind: str + message: str = "Invalid value" + + +class TextValidator(Validator): + kind: str = "text" + max_length: int = 0 + min_length: int = 0 + allow_digits: bool = True + + +class NumericValidator(Validator): + kind: str = "numeric" + max_value: int = 0 + min_value: int = 0 + + +class EmailValidator(Validator): + kind: str = "email" + + +class RegexValidator(Validator): + kind: str = "regex" + regex: str = "" + + +class ExpressionValidator(Validator): + kind: str = "regex" + expression: str = "" + + class Question(Base): kind: str name: str = "" @@ -63,7 +95,7 @@ class Question(Base): max_width: str = "initial" min_width: str = "300px" use_display_values_in_title: bool = True - validators: List[Dict[str, Any]] = [] + validators: List[Validator] = [] extra_js: List[HttpUrl] = [] extra_css: List[HttpUrl] = [] diff --git a/questions/templates.py b/questions/templates.py index b9fb479..257d7e1 100644 --- a/questions/templates.py +++ b/questions/templates.py @@ -42,51 +42,50 @@ PLATFORM_JS = { "jquery": """var survey = new Survey.Model(json); survey.data = data; -$("#questions_form").Survey({ +$("#{}").Survey({{ model:survey, onComplete:sendDataToServer -}); +}}); """, "angular": """window.survey = new Survey.Model(json); survey .onComplete .add(sendDataToServer); survey.data = data; -function onAngularComponentInit() { +function onAngularComponentInit() {{ Survey .SurveyNG .render("surveyElement", {model: survey}); -} +}} var QuestionsApp = ng .core - .Component({selector: 'ng-app', template: '