feat: add initial/basic forms support
This commit is contained in:
		
							parent
							
								
									0604651be5
								
							
						
					
					
						commit
						95d3623a5e
					
				
					 16 changed files with 858 additions and 10 deletions
				
			
		
							
								
								
									
										6
									
								
								docs/api/wuttaweb/forms.base.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/wuttaweb/forms.base.rst
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | ||||||
|  | 
 | ||||||
|  | ``wuttaweb.forms.base`` | ||||||
|  | ======================= | ||||||
|  | 
 | ||||||
|  | .. automodule:: wuttaweb.forms.base | ||||||
|  |    :members: | ||||||
							
								
								
									
										6
									
								
								docs/api/wuttaweb/forms.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/wuttaweb/forms.rst
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,6 @@ | ||||||
|  | 
 | ||||||
|  | ``wuttaweb.forms`` | ||||||
|  | ================== | ||||||
|  | 
 | ||||||
|  | .. automodule:: wuttaweb.forms | ||||||
|  |    :members: | ||||||
|  | @ -9,6 +9,8 @@ | ||||||
| 
 | 
 | ||||||
|    app |    app | ||||||
|    db |    db | ||||||
|  |    forms | ||||||
|  |    forms.base | ||||||
|    handler |    handler | ||||||
|    helpers |    helpers | ||||||
|    menus |    menus | ||||||
|  |  | ||||||
|  | @ -20,12 +20,15 @@ extensions = [ | ||||||
|     'sphinx.ext.autodoc', |     'sphinx.ext.autodoc', | ||||||
|     'sphinx.ext.intersphinx', |     'sphinx.ext.intersphinx', | ||||||
|     'sphinx.ext.viewcode', |     'sphinx.ext.viewcode', | ||||||
|  |     'sphinx.ext.todo', | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
| templates_path = ['_templates'] | templates_path = ['_templates'] | ||||||
| exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] | ||||||
| 
 | 
 | ||||||
| intersphinx_mapping = { | intersphinx_mapping = { | ||||||
|  |     'colander': ('https://docs.pylonsproject.org/projects/colander/en/latest/', None), | ||||||
|  |     'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None), | ||||||
|     'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), |     'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), | ||||||
|     'python': ('https://docs.python.org/3/', None), |     'python': ('https://docs.python.org/3/', None), | ||||||
|     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), |     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None), | ||||||
|  |  | ||||||
|  | @ -32,6 +32,7 @@ requires-python = ">= 3.8" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|         "pyramid>=2", |         "pyramid>=2", | ||||||
|         "pyramid_beaker", |         "pyramid_beaker", | ||||||
|  |         "pyramid_deform", | ||||||
|         "pyramid_mako", |         "pyramid_mako", | ||||||
|         "waitress", |         "waitress", | ||||||
|         "WebHelpers2", |         "WebHelpers2", | ||||||
|  |  | ||||||
|  | @ -110,9 +110,13 @@ def make_pyramid_config(settings): | ||||||
|     The config is initialized with certain features deemed useful for |     The config is initialized with certain features deemed useful for | ||||||
|     all apps. |     all apps. | ||||||
|     """ |     """ | ||||||
|  |     settings.setdefault('pyramid_deform.template_search_path', | ||||||
|  |                         'wuttaweb:templates/deform') | ||||||
|  | 
 | ||||||
|     pyramid_config = Configurator(settings=settings) |     pyramid_config = Configurator(settings=settings) | ||||||
| 
 | 
 | ||||||
|     pyramid_config.include('pyramid_beaker') |     pyramid_config.include('pyramid_beaker') | ||||||
|  |     pyramid_config.include('pyramid_deform') | ||||||
|     pyramid_config.include('pyramid_mako') |     pyramid_config.include('pyramid_mako') | ||||||
| 
 | 
 | ||||||
|     return pyramid_config |     return pyramid_config | ||||||
|  |  | ||||||
							
								
								
									
										31
									
								
								src/wuttaweb/forms/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/wuttaweb/forms/__init__.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,31 @@ | ||||||
|  | # -*- coding: utf-8; -*- | ||||||
|  | ################################################################################ | ||||||
|  | # | ||||||
|  | #  wuttaweb -- Web App for Wutta Framework | ||||||
|  | #  Copyright © 2024 Lance Edgar | ||||||
|  | # | ||||||
|  | #  This file is part of Wutta Framework. | ||||||
|  | # | ||||||
|  | #  Wutta Framework is free software: you can redistribute it and/or modify it | ||||||
|  | #  under the terms of the GNU General Public License as published by the Free | ||||||
|  | #  Software Foundation, either version 3 of the License, or (at your option) any | ||||||
|  | #  later version. | ||||||
|  | # | ||||||
|  | #  Wutta Framework is distributed in the hope that it will be useful, but | ||||||
|  | #  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | ||||||
|  | #  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for | ||||||
|  | #  more details. | ||||||
|  | # | ||||||
|  | #  You should have received a copy of the GNU General Public License along with | ||||||
|  | #  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | # | ||||||
|  | ################################################################################ | ||||||
|  | """ | ||||||
|  | Forms Library | ||||||
|  | 
 | ||||||
|  | The ``wuttaweb.forms`` namespace contains the following: | ||||||
|  | 
 | ||||||
|  | * :class:`~wuttaweb.forms.base.Form` | ||||||
|  | """ | ||||||
|  | 
 | ||||||
|  | from .base import Form | ||||||
							
								
								
									
										421
									
								
								src/wuttaweb/forms/base.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										421
									
								
								src/wuttaweb/forms/base.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,421 @@ | ||||||
|  | # -*- coding: utf-8; -*- | ||||||
|  | ################################################################################ | ||||||
|  | # | ||||||
|  | #  wuttaweb -- Web App for Wutta Framework | ||||||
|  | #  Copyright © 2024 Lance Edgar | ||||||
|  | # | ||||||
|  | #  This file is part of Wutta Framework. | ||||||
|  | # | ||||||
|  | #  Wutta Framework is free software: you can redistribute it and/or modify it | ||||||
|  | #  under the terms of the GNU General Public License as published by the Free | ||||||
|  | #  Software Foundation, either version 3 of the License, or (at your option) any | ||||||
|  | #  later version. | ||||||
|  | # | ||||||
|  | #  Wutta Framework is distributed in the hope that it will be useful, but | ||||||
|  | #  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or | ||||||
|  | #  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for | ||||||
|  | #  more details. | ||||||
|  | # | ||||||
|  | #  You should have received a copy of the GNU General Public License along with | ||||||
|  | #  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>. | ||||||
|  | # | ||||||
|  | ################################################################################ | ||||||
|  | """ | ||||||
|  | Base form classes | ||||||
|  | """ | ||||||
|  | 
 | ||||||
|  | import json | ||||||
|  | import logging | ||||||
|  | 
 | ||||||
|  | import colander | ||||||
|  | import deform | ||||||
|  | from pyramid.renderers import render | ||||||
|  | from webhelpers2.html import HTML | ||||||
|  | 
 | ||||||
|  | from wuttaweb.util import get_form_data | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | log = logging.getLogger(__name__) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class FieldList(list): | ||||||
|  |     """ | ||||||
|  |     Convenience wrapper for a form's field list.  This is a subclass | ||||||
|  |     of :class:`python:list`. | ||||||
|  | 
 | ||||||
|  |     You normally would not need to instantiate this yourself, but it | ||||||
|  |     is used under the hood for e.g. :attr:`Form.fields`. | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|  |     def insert_before(self, field, newfield): | ||||||
|  |         """ | ||||||
|  |         Insert a new field, before an existing field. | ||||||
|  | 
 | ||||||
|  |         :param field: String name for the existing field. | ||||||
|  | 
 | ||||||
|  |         :param newfield: String name for the new field, to be inserted | ||||||
|  |            just before the existing ``field``. | ||||||
|  |         """ | ||||||
|  |         if field in self: | ||||||
|  |             i = self.index(field) | ||||||
|  |             self.insert(i, newfield) | ||||||
|  |         else: | ||||||
|  |             log.warning("field '%s' not found, will append new field: %s", | ||||||
|  |                         field, newfield) | ||||||
|  |             self.append(newfield) | ||||||
|  | 
 | ||||||
|  |     def insert_after(self, field, newfield): | ||||||
|  |         """ | ||||||
|  |         Insert a new field, after an existing field. | ||||||
|  | 
 | ||||||
|  |         :param field: String name for the existing field. | ||||||
|  | 
 | ||||||
|  |         :param newfield: String name for the new field, to be inserted | ||||||
|  |            just after the existing ``field``. | ||||||
|  |         """ | ||||||
|  |         if field in self: | ||||||
|  |             i = self.index(field) | ||||||
|  |             self.insert(i + 1, newfield) | ||||||
|  |         else: | ||||||
|  |             log.warning("field '%s' not found, will append new field: %s", | ||||||
|  |                         field, newfield) | ||||||
|  |             self.append(newfield) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class Form: | ||||||
|  |     """ | ||||||
|  |     Base class for all forms. | ||||||
|  | 
 | ||||||
|  |     :param request: Reference to current :term:`request` object. | ||||||
|  | 
 | ||||||
|  |     :param fields: List of field names for the form.  This is | ||||||
|  |        optional; if not specified an attempt will be made to deduce | ||||||
|  |        the list automatically.  See also :attr:`fields`. | ||||||
|  | 
 | ||||||
|  |     :param schema: Colander-based schema object for the form.  This is | ||||||
|  |        optional; if not specified an attempt will be made to construct | ||||||
|  |        one automatically.  See also :meth:`get_schema()`. | ||||||
|  | 
 | ||||||
|  |     :param labels: Optional dict of default field labels. | ||||||
|  | 
 | ||||||
|  |     .. note:: | ||||||
|  | 
 | ||||||
|  |        Some parameters are not explicitly described above.  However | ||||||
|  |        their corresponding attributes are described below. | ||||||
|  | 
 | ||||||
|  |     Form instances contain the following attributes: | ||||||
|  | 
 | ||||||
|  |     .. attribute:: fields | ||||||
|  | 
 | ||||||
|  |        :class:`FieldList` instance containing string field names for | ||||||
|  |        the form.  By default, fields will appear in the same order as | ||||||
|  |        they are in this list. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: request | ||||||
|  | 
 | ||||||
|  |        Reference to current :term:`request` object. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: action_url | ||||||
|  | 
 | ||||||
|  |        String URL to which the form should be submitted, if applicable. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: vue_tagname | ||||||
|  | 
 | ||||||
|  |        String name for Vue component tag.  By default this is | ||||||
|  |        ``'wutta-form'``.  See also :meth:`render_vue_tag()`. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: align_buttons_right | ||||||
|  | 
 | ||||||
|  |        Flag indicating whether the buttons (submit, cancel etc.) | ||||||
|  |        should be aligned to the right of the area below the form.  If | ||||||
|  |        not set, the buttons are left-aligned. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: auto_disable_submit | ||||||
|  | 
 | ||||||
|  |        Flag indicating whether the submit button should be | ||||||
|  |        auto-disabled, whenever the form is submitted. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: button_label_submit | ||||||
|  | 
 | ||||||
|  |        String label for the form submit button.  Default is ``"Save"``. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: button_icon_submit | ||||||
|  | 
 | ||||||
|  |        String icon name for the form submit button.  Default is ``'save'``. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: show_button_reset | ||||||
|  | 
 | ||||||
|  |        Flag indicating whether a Reset button should be shown. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: validated | ||||||
|  | 
 | ||||||
|  |        If the :meth:`validate()` method was called, and it succeeded, | ||||||
|  |        this will be set to the validated data dict. | ||||||
|  | 
 | ||||||
|  |        Note that in all other cases, this attribute may not exist. | ||||||
|  |     """ | ||||||
|  | 
 | ||||||
|  |     def __init__( | ||||||
|  |             self, | ||||||
|  |             request, | ||||||
|  |             fields=None, | ||||||
|  |             schema=None, | ||||||
|  |             labels={}, | ||||||
|  |             action_url=None, | ||||||
|  |             vue_tagname='wutta-form', | ||||||
|  |             align_buttons_right=False, | ||||||
|  |             auto_disable_submit=True, | ||||||
|  |             button_label_submit="Save", | ||||||
|  |             button_icon_submit='save', | ||||||
|  |             show_button_reset=False, | ||||||
|  |     ): | ||||||
|  |         self.request = request | ||||||
|  |         self.schema = schema | ||||||
|  |         self.labels = labels or {} | ||||||
|  |         self.action_url = action_url | ||||||
|  |         self.vue_tagname = vue_tagname | ||||||
|  |         self.align_buttons_right = align_buttons_right | ||||||
|  |         self.auto_disable_submit = auto_disable_submit | ||||||
|  |         self.button_label_submit = button_label_submit | ||||||
|  |         self.button_icon_submit = button_icon_submit | ||||||
|  |         self.show_button_reset = show_button_reset | ||||||
|  | 
 | ||||||
|  |         self.config = self.request.wutta_config | ||||||
|  |         self.app = self.config.get_app() | ||||||
|  | 
 | ||||||
|  |         if fields is not None: | ||||||
|  |             self.set_fields(fields) | ||||||
|  |         elif self.schema: | ||||||
|  |             self.set_fields([f.name for f in self.schema]) | ||||||
|  |         else: | ||||||
|  |             self.fields = None | ||||||
|  | 
 | ||||||
|  |     def __contains__(self, name): | ||||||
|  |         """ | ||||||
|  |         Custom logic for the ``in`` operator, to allow easily checking | ||||||
|  |         if the form contains a given field:: | ||||||
|  | 
 | ||||||
|  |            myform = Form() | ||||||
|  |            if 'somefield' in myform: | ||||||
|  |                print("my form has some field") | ||||||
|  |         """ | ||||||
|  |         return bool(self.fields and name in self.fields) | ||||||
|  | 
 | ||||||
|  |     def __iter__(self): | ||||||
|  |         """ | ||||||
|  |         Custom logic to allow iterating over form field names:: | ||||||
|  | 
 | ||||||
|  |            myform = Form(fields=['foo', 'bar']) | ||||||
|  |            for fieldname in myform: | ||||||
|  |                print(fieldname) | ||||||
|  |         """ | ||||||
|  |         return iter(self.fields) | ||||||
|  | 
 | ||||||
|  |     @property | ||||||
|  |     def vue_component(self): | ||||||
|  |         """ | ||||||
|  |         String name for the Vue component, e.g. ``'WuttaForm'``. | ||||||
|  | 
 | ||||||
|  |         This is a generated value based on :attr:`vue_tagname`. | ||||||
|  |         """ | ||||||
|  |         words = self.vue_tagname.split('-') | ||||||
|  |         return ''.join([word.capitalize() for word in words]) | ||||||
|  | 
 | ||||||
|  |     def set_fields(self, fields): | ||||||
|  |         """ | ||||||
|  |         Explicitly set the list of form fields. | ||||||
|  | 
 | ||||||
|  |         This will overwrite :attr:`fields` with a new | ||||||
|  |         :class:`FieldList` instance. | ||||||
|  | 
 | ||||||
|  |         :param fields: List of string field names. | ||||||
|  |         """ | ||||||
|  |         self.fields = FieldList(fields) | ||||||
|  | 
 | ||||||
|  |     def set_label(self, key, label): | ||||||
|  |         """ | ||||||
|  |         Set the label for given field name. | ||||||
|  | 
 | ||||||
|  |         See also :meth:`get_label()`. | ||||||
|  |         """ | ||||||
|  |         self.labels[key] = label | ||||||
|  | 
 | ||||||
|  |         # update schema if necessary | ||||||
|  |         if self.schema and key in self.schema: | ||||||
|  |             self.schema[key].title = label | ||||||
|  | 
 | ||||||
|  |     def get_label(self, key): | ||||||
|  |         """ | ||||||
|  |         Get the label for given field name. | ||||||
|  | 
 | ||||||
|  |         Note that this will always return a string, auto-generating | ||||||
|  |         the label if needed. | ||||||
|  | 
 | ||||||
|  |         See also :meth:`set_label()`. | ||||||
|  |         """ | ||||||
|  |         return self.labels.get(key, self.app.make_title(key)) | ||||||
|  | 
 | ||||||
|  |     def get_schema(self): | ||||||
|  |         """ | ||||||
|  |         Return the :class:`colander:colander.Schema` object for the | ||||||
|  |         form, generating it automatically if necessary. | ||||||
|  |         """ | ||||||
|  |         if not self.schema: | ||||||
|  |             raise NotImplementedError | ||||||
|  | 
 | ||||||
|  |         return self.schema | ||||||
|  | 
 | ||||||
|  |     def get_deform(self): | ||||||
|  |         """ | ||||||
|  |         Return the :class:`deform:deform.Form` instance for the form, | ||||||
|  |         generating it automatically if necessary. | ||||||
|  |         """ | ||||||
|  |         if not hasattr(self, 'deform_form'): | ||||||
|  |             schema = self.get_schema() | ||||||
|  |             form = deform.Form(schema) | ||||||
|  |             self.deform_form = form | ||||||
|  | 
 | ||||||
|  |         return self.deform_form | ||||||
|  | 
 | ||||||
|  |     def render_vue_tag(self, **kwargs): | ||||||
|  |         """ | ||||||
|  |         Render the Vue component tag for the form. | ||||||
|  | 
 | ||||||
|  |         By default this simply returns: | ||||||
|  | 
 | ||||||
|  |         .. code-block:: html | ||||||
|  | 
 | ||||||
|  |            <wutta-form></wutta-form> | ||||||
|  | 
 | ||||||
|  |         The actual output will depend on various form attributes, in | ||||||
|  |         particular :attr:`vue_tagname`. | ||||||
|  |         """ | ||||||
|  |         return HTML.tag(self.vue_tagname, **kwargs) | ||||||
|  | 
 | ||||||
|  |     def render_vue_template( | ||||||
|  |             self, | ||||||
|  |             template='/forms/vue_template.mako', | ||||||
|  |             **context): | ||||||
|  |         """ | ||||||
|  |         Render the Vue template block for the form. | ||||||
|  | 
 | ||||||
|  |         This returns something like: | ||||||
|  | 
 | ||||||
|  |         .. code-block:: none | ||||||
|  | 
 | ||||||
|  |            <script type="text/x-template" id="wutta-form-template"> | ||||||
|  |              <form> | ||||||
|  |                <!-- fields etc. --> | ||||||
|  |              </form> | ||||||
|  |            </script> | ||||||
|  | 
 | ||||||
|  |         .. todo:: | ||||||
|  | 
 | ||||||
|  |            Why can't Sphinx render the above code block as 'html' ? | ||||||
|  | 
 | ||||||
|  |            It acts like it can't handle a ``<script>`` tag at all? | ||||||
|  | 
 | ||||||
|  |         Actual output will of course depend on form attributes, i.e. | ||||||
|  |         :attr:`vue_tagname` and :attr:`fields` list etc. | ||||||
|  | 
 | ||||||
|  |         :param template: Path to Mako template which is used to render | ||||||
|  |            the output. | ||||||
|  |         """ | ||||||
|  |         context['form'] = self | ||||||
|  |         context.setdefault('form_attrs', {}) | ||||||
|  | 
 | ||||||
|  |         # auto disable button on submit | ||||||
|  |         if self.auto_disable_submit: | ||||||
|  |             context['form_attrs']['@submit'] = 'formSubmitting = true' | ||||||
|  | 
 | ||||||
|  |         output = render(template, context) | ||||||
|  |         return HTML.literal(output) | ||||||
|  | 
 | ||||||
|  |     def render_vue_field(self, fieldname): | ||||||
|  |         """ | ||||||
|  |         Render the given field completely, i.e. ``<b-field>`` wrapper | ||||||
|  |         with label and containing a widget. | ||||||
|  | 
 | ||||||
|  |         Actual output will depend on the field attributes etc. | ||||||
|  |         """ | ||||||
|  |         dform = self.get_deform() | ||||||
|  |         field = dform[fieldname] | ||||||
|  | 
 | ||||||
|  |         # render the field widget or whatever | ||||||
|  |         html = field.serialize() | ||||||
|  |         html = HTML.literal(html) | ||||||
|  | 
 | ||||||
|  |         # render field label | ||||||
|  |         label = self.get_label(fieldname) | ||||||
|  | 
 | ||||||
|  |         # b-field attrs | ||||||
|  |         attrs = { | ||||||
|  |             ':horizontal': 'true', | ||||||
|  |             'label': label, | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return HTML.tag('b-field', c=[html], **attrs) | ||||||
|  | 
 | ||||||
|  |     def get_vue_field_value(self, field): | ||||||
|  |         """ | ||||||
|  |         This method returns a JSON string which will be assigned as | ||||||
|  |         the initial model value for the given field.  This JSON will | ||||||
|  |         be written as part of the overall response, to be interpreted | ||||||
|  |         on the client side. | ||||||
|  | 
 | ||||||
|  |         Again, this must return a *string* such as: | ||||||
|  | 
 | ||||||
|  |         * ``'null'`` | ||||||
|  |         * ``'{"foo": "bar"}'`` | ||||||
|  | 
 | ||||||
|  |         In practice this calls :meth:`jsonify_value()` to convert the | ||||||
|  |         ``field.cstruct`` value to string. | ||||||
|  |         """ | ||||||
|  |         if isinstance(field, str): | ||||||
|  |             dform = self.get_deform() | ||||||
|  |             field = dform[field] | ||||||
|  | 
 | ||||||
|  |         return self.jsonify_value(field.cstruct) | ||||||
|  | 
 | ||||||
|  |     def jsonify_value(self, value): | ||||||
|  |         """ | ||||||
|  |         Convert a Python value to JSON string. | ||||||
|  | 
 | ||||||
|  |         See also :meth:`get_vue_field_value()`. | ||||||
|  |         """ | ||||||
|  |         if value is colander.null: | ||||||
|  |             return 'null' | ||||||
|  | 
 | ||||||
|  |         return json.dumps(value) | ||||||
|  | 
 | ||||||
|  |     def validate(self): | ||||||
|  |         """ | ||||||
|  |         Try to validate the form. | ||||||
|  | 
 | ||||||
|  |         This should work whether request data was submitted as classic | ||||||
|  |         POST data, or as JSON body. | ||||||
|  | 
 | ||||||
|  |         If the form data is valid, this method returns the data dict. | ||||||
|  |         This data dict is also then available on the form object via | ||||||
|  |         the :attr:`validated` attribute. | ||||||
|  | 
 | ||||||
|  |         However if the data is not valid, ``False`` is returned, and | ||||||
|  |         there will be no :attr:`validated` attribute. | ||||||
|  | 
 | ||||||
|  |         :returns: Data dict, or ``False``. | ||||||
|  |         """ | ||||||
|  |         if hasattr(self, 'validated'): | ||||||
|  |             del self.validated | ||||||
|  | 
 | ||||||
|  |         if self.request.method != 'POST': | ||||||
|  |             return False | ||||||
|  | 
 | ||||||
|  |         dform = self.get_deform() | ||||||
|  |         controls = get_form_data(self.request).items() | ||||||
|  | 
 | ||||||
|  |         try: | ||||||
|  |             self.validated = dform.validate(controls) | ||||||
|  |         except deform.ValidationFailure: | ||||||
|  |             return False | ||||||
|  | 
 | ||||||
|  |         return self.validated | ||||||
							
								
								
									
										8
									
								
								src/wuttaweb/templates/deform/password.pt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/wuttaweb/templates/deform/password.pt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | ||||||
|  | <div tal:omit-tag="" | ||||||
|  |      tal:define="name name|field.name; | ||||||
|  |                  vmodel vmodel|'model_'+name;"> | ||||||
|  |   <b-input name="${name}" | ||||||
|  |            v-model="${vmodel}" | ||||||
|  |            type="password" | ||||||
|  |            tal:attributes="attributes|field.widget.attributes|{};" /> | ||||||
|  | </div> | ||||||
							
								
								
									
										7
									
								
								src/wuttaweb/templates/deform/textinput.pt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/wuttaweb/templates/deform/textinput.pt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | ||||||
|  | <div tal:omit-tag="" | ||||||
|  |      tal:define="name name|field.name; | ||||||
|  |                  vmodel vmodel|'model_'+name;"> | ||||||
|  |   <b-input name="${name}" | ||||||
|  |            v-model="${vmodel}" | ||||||
|  |            tal:attributes="attributes|field.widget.attributes|{};" /> | ||||||
|  | </div> | ||||||
							
								
								
									
										18
									
								
								src/wuttaweb/templates/form.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/wuttaweb/templates/form.mako
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | ||||||
|  | ## -*- coding: utf-8; -*- | ||||||
|  | <%inherit file="/page.mako" /> | ||||||
|  | 
 | ||||||
|  | <%def name="render_this_page_template()"> | ||||||
|  |   ${parent.render_this_page_template()} | ||||||
|  |   ${form.render_vue_template()} | ||||||
|  | </%def> | ||||||
|  | 
 | ||||||
|  | <%def name="finalize_this_page_vars()"> | ||||||
|  |   ${parent.finalize_this_page_vars()} | ||||||
|  |   <script> | ||||||
|  |     ${form.vue_component}.data = function() { return ${form.vue_component}Data } | ||||||
|  |     Vue.component('${form.vue_tagname}', ${form.vue_component}) | ||||||
|  |   </script> | ||||||
|  | </%def> | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ${parent.body()} | ||||||
							
								
								
									
										58
									
								
								src/wuttaweb/templates/forms/vue_template.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/wuttaweb/templates/forms/vue_template.mako
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | ||||||
|  | ## -*- coding: utf-8; -*- | ||||||
|  | 
 | ||||||
|  | <script type="text/x-template" id="${form.vue_tagname}-template"> | ||||||
|  |   ${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)} | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       % for fieldname in form: | ||||||
|  |           ${form.render_vue_field(fieldname)} | ||||||
|  |       % endfor | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: end; width: 100%;"> | ||||||
|  | 
 | ||||||
|  |       % if form.show_button_reset: | ||||||
|  |           <b-button native-type="reset"> | ||||||
|  |             Reset | ||||||
|  |           </b-button> | ||||||
|  |       % endif | ||||||
|  | 
 | ||||||
|  |       <b-button type="is-primary" | ||||||
|  |                 native-type="submit" | ||||||
|  |                 % if form.auto_disable_submit: | ||||||
|  |                     :disabled="formSubmitting" | ||||||
|  |                 % endif | ||||||
|  |                 icon-pack="fas" | ||||||
|  |                 icon-left="${form.button_icon_submit}"> | ||||||
|  |         % if form.auto_disable_submit: | ||||||
|  |             {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} | ||||||
|  |         % else: | ||||||
|  |             ${form.button_label_submit} | ||||||
|  |         % endif | ||||||
|  |       </b-button> | ||||||
|  | 
 | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |   ${h.end_form()} | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | 
 | ||||||
|  |   let ${form.vue_component} = { | ||||||
|  |       template: '#${form.vue_tagname}-template', | ||||||
|  |       methods: {}, | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   let ${form.vue_component}Data = { | ||||||
|  | 
 | ||||||
|  |       ## field model values | ||||||
|  |       % for key in form: | ||||||
|  |           model_${key}: ${form.get_vue_field_value(key)|n}, | ||||||
|  |       % endfor | ||||||
|  | 
 | ||||||
|  |       % if form.auto_disable_submit: | ||||||
|  |           formSubmitting: false, | ||||||
|  |       % endif | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | </script> | ||||||
|  | @ -24,6 +24,10 @@ | ||||||
| Base Logic for Views | Base Logic for Views | ||||||
| """ | """ | ||||||
| 
 | 
 | ||||||
|  | from pyramid import httpexceptions | ||||||
|  | 
 | ||||||
|  | from wuttaweb import forms | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class View: | class View: | ||||||
|     """ |     """ | ||||||
|  | @ -35,8 +39,7 @@ class View: | ||||||
| 
 | 
 | ||||||
|     .. attribute:: request |     .. attribute:: request | ||||||
| 
 | 
 | ||||||
|        Reference to the current |        Reference to the current :term:`request` object. | ||||||
|        :class:`pyramid:pyramid.request.Request` object. |  | ||||||
| 
 | 
 | ||||||
|     .. attribute:: app |     .. attribute:: app | ||||||
| 
 | 
 | ||||||
|  | @ -51,3 +54,30 @@ class View: | ||||||
|         self.request = request |         self.request = request | ||||||
|         self.config = self.request.wutta_config |         self.config = self.request.wutta_config | ||||||
|         self.app = self.config.get_app() |         self.app = self.config.get_app() | ||||||
|  | 
 | ||||||
|  |     def make_form(self, **kwargs): | ||||||
|  |         """ | ||||||
|  |         Make and return a new :class:`~wuttaweb.forms.base.Form` | ||||||
|  |         instance, per the given ``kwargs``. | ||||||
|  | 
 | ||||||
|  |         This is the "default" form factory which merely invokes | ||||||
|  |         the constructor. | ||||||
|  |         """ | ||||||
|  |         return forms.Form(self.request, **kwargs) | ||||||
|  | 
 | ||||||
|  |     def redirect(self, url, **kwargs): | ||||||
|  |         """ | ||||||
|  |         Convenience method to return a HTTP 302 response. | ||||||
|  | 
 | ||||||
|  |         Note that this technically returns an "exception" - so in | ||||||
|  |         your code, you can either return that error, or raise it:: | ||||||
|  | 
 | ||||||
|  |            return self.redirect('/') | ||||||
|  |            # ..or | ||||||
|  |            raise self.redirect('/') | ||||||
|  | 
 | ||||||
|  |         Which you should do will depend on context, but raising the | ||||||
|  |         error is always "safe" since Pyramid will handle that | ||||||
|  |         correctly no matter what. | ||||||
|  |         """ | ||||||
|  |         return httpexceptions.HTTPFound(location=url, **kwargs) | ||||||
|  |  | ||||||
							
								
								
									
										0
									
								
								tests/forms/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/forms/__init__.py
									
										
									
									
									
										Normal file
									
								
							
							
								
								
									
										241
									
								
								tests/forms/test_base.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								tests/forms/test_base.py
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,241 @@ | ||||||
|  | # -*- coding: utf-8; -*- | ||||||
|  | 
 | ||||||
|  | from unittest import TestCase | ||||||
|  | 
 | ||||||
|  | import colander | ||||||
|  | import deform | ||||||
|  | from pyramid import testing | ||||||
|  | 
 | ||||||
|  | from wuttjamaican.conf import WuttaConfig | ||||||
|  | from wuttaweb.forms import base | ||||||
|  | from wuttaweb import helpers | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestFieldList(TestCase): | ||||||
|  | 
 | ||||||
|  |     def test_insert_before(self): | ||||||
|  |         fields = base.FieldList(['f1', 'f2']) | ||||||
|  |         self.assertEqual(fields, ['f1', 'f2']) | ||||||
|  | 
 | ||||||
|  |         # typical | ||||||
|  |         fields.insert_before('f1', 'XXX') | ||||||
|  |         self.assertEqual(fields, ['XXX', 'f1', 'f2']) | ||||||
|  |         fields.insert_before('f2', 'YYY') | ||||||
|  |         self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2']) | ||||||
|  | 
 | ||||||
|  |         # appends new field if reference field is invalid | ||||||
|  |         fields.insert_before('f3', 'ZZZ') | ||||||
|  |         self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ']) | ||||||
|  | 
 | ||||||
|  |     def test_insert_after(self): | ||||||
|  |         fields = base.FieldList(['f1', 'f2']) | ||||||
|  |         self.assertEqual(fields, ['f1', 'f2']) | ||||||
|  | 
 | ||||||
|  |         # typical | ||||||
|  |         fields.insert_after('f1', 'XXX') | ||||||
|  |         self.assertEqual(fields, ['f1', 'XXX', 'f2']) | ||||||
|  |         fields.insert_after('XXX', 'YYY') | ||||||
|  |         self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2']) | ||||||
|  | 
 | ||||||
|  |         # appends new field if reference field is invalid | ||||||
|  |         fields.insert_after('f3', 'ZZZ') | ||||||
|  |         self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ']) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestForm(TestCase): | ||||||
|  | 
 | ||||||
|  |     def setUp(self): | ||||||
|  |         self.config = WuttaConfig() | ||||||
|  |         self.request = testing.DummyRequest(wutta_config=self.config) | ||||||
|  | 
 | ||||||
|  |         self.pyramid_config = testing.setUp(request=self.request, settings={ | ||||||
|  |             'mako.directories': ['wuttaweb:templates'], | ||||||
|  |             'pyramid_deform.template_search_path': 'wuttaweb:templates/deform', | ||||||
|  |         }) | ||||||
|  | 
 | ||||||
|  |     def tearDown(self): | ||||||
|  |         testing.tearDown() | ||||||
|  | 
 | ||||||
|  |     def make_form(self, request=None, **kwargs): | ||||||
|  |         return base.Form(request or self.request, **kwargs) | ||||||
|  | 
 | ||||||
|  |     def make_schema(self): | ||||||
|  |         schema = colander.Schema(children=[ | ||||||
|  |             colander.SchemaNode(colander.String(), | ||||||
|  |                                 name='foo'), | ||||||
|  |             colander.SchemaNode(colander.String(), | ||||||
|  |                                 name='bar'), | ||||||
|  |         ]) | ||||||
|  |         return schema | ||||||
|  | 
 | ||||||
|  |     def test_init_with_none(self): | ||||||
|  |         form = self.make_form() | ||||||
|  |         self.assertIsNone(form.fields) | ||||||
|  | 
 | ||||||
|  |     def test_init_with_fields(self): | ||||||
|  |         form = self.make_form(fields=['foo', 'bar']) | ||||||
|  |         self.assertEqual(form.fields, ['foo', 'bar']) | ||||||
|  | 
 | ||||||
|  |     def test_init_with_schema(self): | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         self.assertEqual(form.fields, ['foo', 'bar']) | ||||||
|  | 
 | ||||||
|  |     def test_vue_tagname(self): | ||||||
|  |         form = self.make_form() | ||||||
|  |         self.assertEqual(form.vue_tagname, 'wutta-form') | ||||||
|  | 
 | ||||||
|  |     def test_vue_component(self): | ||||||
|  |         form = self.make_form() | ||||||
|  |         self.assertEqual(form.vue_component, 'WuttaForm') | ||||||
|  | 
 | ||||||
|  |     def test_contains(self): | ||||||
|  |         form = self.make_form(fields=['foo', 'bar']) | ||||||
|  |         self.assertIn('foo', form) | ||||||
|  |         self.assertNotIn('baz', form) | ||||||
|  | 
 | ||||||
|  |     def test_iter(self): | ||||||
|  |         form = self.make_form(fields=['foo', 'bar']) | ||||||
|  | 
 | ||||||
|  |         fields = list(iter(form)) | ||||||
|  |         self.assertEqual(fields, ['foo', 'bar']) | ||||||
|  | 
 | ||||||
|  |         fields = [] | ||||||
|  |         for field in form: | ||||||
|  |             fields.append(field) | ||||||
|  |         self.assertEqual(fields, ['foo', 'bar']) | ||||||
|  | 
 | ||||||
|  |     def test_set_fields(self): | ||||||
|  |         form = self.make_form(fields=['foo', 'bar']) | ||||||
|  |         self.assertEqual(form.fields, ['foo', 'bar']) | ||||||
|  |         form.set_fields(['baz']) | ||||||
|  |         self.assertEqual(form.fields, ['baz']) | ||||||
|  | 
 | ||||||
|  |     def test_get_schema(self): | ||||||
|  |         form = self.make_form() | ||||||
|  |         self.assertIsNone(form.schema) | ||||||
|  | 
 | ||||||
|  |         # provided schema is returned | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         self.assertIs(form.schema, schema) | ||||||
|  |         self.assertIs(form.get_schema(), schema) | ||||||
|  | 
 | ||||||
|  |         # auto-generating schema not yet supported | ||||||
|  |         form = self.make_form(fields=['foo', 'bar']) | ||||||
|  |         self.assertIsNone(form.schema) | ||||||
|  |         self.assertRaises(NotImplementedError, form.get_schema) | ||||||
|  | 
 | ||||||
|  |     def test_get_deform(self): | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         self.assertFalse(hasattr(form, 'deform_form')) | ||||||
|  |         dform = form.get_deform() | ||||||
|  |         self.assertIsInstance(dform, deform.Form) | ||||||
|  |         self.assertIs(form.deform_form, dform) | ||||||
|  | 
 | ||||||
|  |     def test_get_label(self): | ||||||
|  |         form = self.make_form(fields=['foo', 'bar']) | ||||||
|  |         self.assertEqual(form.get_label('foo'), "Foo") | ||||||
|  |         form.set_label('foo', "Baz") | ||||||
|  |         self.assertEqual(form.get_label('foo'), "Baz") | ||||||
|  | 
 | ||||||
|  |     def test_set_label(self): | ||||||
|  |         form = self.make_form(fields=['foo', 'bar']) | ||||||
|  |         self.assertEqual(form.get_label('foo'), "Foo") | ||||||
|  |         form.set_label('foo', "Baz") | ||||||
|  |         self.assertEqual(form.get_label('foo'), "Baz") | ||||||
|  | 
 | ||||||
|  |         # schema should be updated when setting label | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         form.set_label('foo', "Woohoo") | ||||||
|  |         self.assertEqual(form.get_label('foo'), "Woohoo") | ||||||
|  |         self.assertEqual(schema['foo'].title, "Woohoo") | ||||||
|  | 
 | ||||||
|  |     def test_render_vue_tag(self): | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         html = form.render_vue_tag() | ||||||
|  |         self.assertEqual(html, '<wutta-form></wutta-form>') | ||||||
|  | 
 | ||||||
|  |     def test_render_vue_template(self): | ||||||
|  |         self.pyramid_config.include('pyramid_mako') | ||||||
|  |         self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render', | ||||||
|  |                                            'pyramid.events.BeforeRender') | ||||||
|  | 
 | ||||||
|  |         # form button is disabled on @submit by default | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         html = form.render_vue_template() | ||||||
|  |         self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) | ||||||
|  |         self.assertIn('@submit', html) | ||||||
|  | 
 | ||||||
|  |         # but not if form is configured otherwise | ||||||
|  |         form = self.make_form(schema=schema, auto_disable_submit=False) | ||||||
|  |         html = form.render_vue_template() | ||||||
|  |         self.assertIn('<script type="text/x-template" id="wutta-form-template">', html) | ||||||
|  |         self.assertNotIn('@submit', html) | ||||||
|  | 
 | ||||||
|  |     def test_render_vue_field(self): | ||||||
|  |         self.pyramid_config.include('pyramid_deform') | ||||||
|  | 
 | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         html = form.render_vue_field('foo') | ||||||
|  |         self.assertIn('<b-field :horizontal="true" label="Foo">', html) | ||||||
|  |         self.assertIn('<b-input name="foo"', html) | ||||||
|  | 
 | ||||||
|  |     def test_get_vue_field_value(self): | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  | 
 | ||||||
|  |         # null field value | ||||||
|  |         value = form.get_vue_field_value('foo') | ||||||
|  |         self.assertEqual(value, 'null') | ||||||
|  | 
 | ||||||
|  |         # non-default / explicit value | ||||||
|  |         # TODO: surely need a different approach to set value | ||||||
|  |         dform = form.get_deform() | ||||||
|  |         dform['foo'].cstruct = 'blarg' | ||||||
|  |         value = form.get_vue_field_value('foo') | ||||||
|  |         self.assertEqual(value, '"blarg"') | ||||||
|  | 
 | ||||||
|  |     def test_jsonify_value(self): | ||||||
|  |         form = self.make_form() | ||||||
|  | 
 | ||||||
|  |         # null field value | ||||||
|  |         value = form.jsonify_value(colander.null) | ||||||
|  |         self.assertEqual(value, 'null') | ||||||
|  |         value = form.jsonify_value(None) | ||||||
|  |         self.assertEqual(value, 'null') | ||||||
|  | 
 | ||||||
|  |         # string value | ||||||
|  |         value = form.jsonify_value('blarg') | ||||||
|  |         self.assertEqual(value, '"blarg"') | ||||||
|  | 
 | ||||||
|  |     def test_validate(self): | ||||||
|  |         schema = self.make_schema() | ||||||
|  |         form = self.make_form(schema=schema) | ||||||
|  |         self.assertFalse(hasattr(form, 'validated')) | ||||||
|  | 
 | ||||||
|  |         # will not validate unless request is POST | ||||||
|  |         self.request.POST = {'foo': 'blarg', 'bar': 'baz'} | ||||||
|  |         self.request.method = 'GET' | ||||||
|  |         self.assertFalse(form.validate()) | ||||||
|  |         self.request.method = 'POST' | ||||||
|  |         data = form.validate() | ||||||
|  |         self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'}) | ||||||
|  | 
 | ||||||
|  |         # validating a second type updates form.validated | ||||||
|  |         self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'} | ||||||
|  |         data = form.validate() | ||||||
|  |         self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'}) | ||||||
|  |         self.assertIs(form.validated, data) | ||||||
|  | 
 | ||||||
|  |         # bad data does not validate | ||||||
|  |         self.request.POST = {'foo': 42, 'bar': None} | ||||||
|  |         self.assertFalse(form.validate()) | ||||||
|  |         dform = form.get_deform() | ||||||
|  |         self.assertEqual(len(dform.error.children), 2) | ||||||
|  |         self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string") | ||||||
|  | @ -3,19 +3,31 @@ | ||||||
| from unittest import TestCase | from unittest import TestCase | ||||||
| 
 | 
 | ||||||
| from pyramid import testing | from pyramid import testing | ||||||
|  | from pyramid.httpexceptions import HTTPFound | ||||||
| 
 | 
 | ||||||
| from wuttjamaican.conf import WuttaConfig | from wuttjamaican.conf import WuttaConfig | ||||||
| from wuttaweb.views import base | from wuttaweb.views import base | ||||||
|  | from wuttaweb.forms import Form | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class TestView(TestCase): | class TestView(TestCase): | ||||||
| 
 | 
 | ||||||
|     def test_basic(self): |     def setUp(self): | ||||||
|         config = WuttaConfig() |         self.config = WuttaConfig() | ||||||
|         request = testing.DummyRequest() |         self.app = self.config.get_app() | ||||||
|         request.wutta_config = config |         self.request = testing.DummyRequest(wutta_config=self.config) | ||||||
|  |         self.view = base.View(self.request) | ||||||
| 
 | 
 | ||||||
|         view = base.View(request) |     def test_basic(self): | ||||||
|         self.assertIs(view.request, request) |         self.assertIs(self.view.request, self.request) | ||||||
|         self.assertIs(view.config, config) |         self.assertIs(self.view.config, self.config) | ||||||
|         self.assertIs(view.app, config.get_app()) |         self.assertIs(self.view.app, self.app) | ||||||
|  | 
 | ||||||
|  |     def test_make_form(self): | ||||||
|  |         form = self.view.make_form() | ||||||
|  |         self.assertIsInstance(form, Form) | ||||||
|  | 
 | ||||||
|  |     def test_redirect(self): | ||||||
|  |         error = self.view.redirect('/') | ||||||
|  |         self.assertIsInstance(error, HTTPFound) | ||||||
|  |         self.assertEqual(error.location, '/') | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Lance Edgar
						Lance Edgar