diff --git a/CHANGES.txt b/CHANGES.txt index 6a0779c..98477d6 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,12 @@ +Next release +------------ + +- Add ``ignore_buttons`` option to the FormView ``__call__`` method. + With this set to ``True``, calling a FormView will result in no button + handler methods (success or failure) being called during proccessing. + This option allows such a button handler method to render the FormView + as a response. Without this option, infinite recursion will result. + 0.2 (2013-08-01) ---------------- diff --git a/docs/api.rst b/docs/api.rst index 658b2a5..d5c1181 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,14 +5,15 @@ API Documentation .. automodule:: pyramid_deform :members: includeme -Form view ---------- +Form views +---------- .. autoclass:: FormView :members: .. automethod:: __call__ + Other ----- diff --git a/docs/index.rst b/docs/index.rst index f9a36d7..6608f6f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,36 +17,82 @@ Topics Installation ------------ -Install using setuptools, e.g. (within a virtualenv):: +Install using ``setuptools`` or ``pip``, e.g. (within a virtualenv):: $ easy_install pyramid_deform -Configuring translations ------------------------- +or:: + + $ pip install pyramid_deform + +You can also include ``pyramid_deform`` as a ``setup_requires`` dependency +in your ``setuptools``-compatible project. + +Once installed, continue with `Configuration`_ of this package. + + +Configuration +------------- + +Basic configuration +^^^^^^^^^^^^^^^^^^^ + +``pyramid_deform`` provides an ``includeme`` hook that will configure your +``Pyramid`` environment accordingly. It will: + +* Configure and register translations for Deform and Colander +* Configure template search paths for Deform +* Add a static view for the Deform JavaScript and CSS resources -pyramid_deform provides an ``includeme`` hook that will set up translation -paths so that the translations for deform and colander are registered. It -also adds a Pyramid static view for the deform JavaScript and CSS resources. To use this in your project, add ``pyramid_deform`` to the -``pyramid.includes`` in your PasteDeploy configuration file. An example:: +``pyramid.includes`` in your PasteDeploy configuration file. For example:: [myapp:main] ... pyramid.includes = pyramid_debugtoolbar pyramid_tm pyramid_deform +You may also use the ``include`` method against a Pyramid configurator +(commonly seen as a `config` object) like so:: + + def main(global_config, **settings): + """ This function returns a Pyramid WSGI application. + """ + config = Configurator(settings=settings) + config.include('pyramid_deform') + ... + + Configuring template search paths ---------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -pyramid_deform allows you to add template search paths in the +``pyramid_deform`` allows you to add one or more template search paths in its configuration. An example:: [myapp:main] ... pyramid_deform.template_search_path = myapp:templates/deform + my.extra:templates/deform/default + ... Thus, if you put a ``form.pt`` into your application's ``templates/deform`` directory, that will override deform's default -``form.pt``. +``form.pt``. Similarly, if you put another ``form.pt`` into the +given directory within the ``my.extra`` package, then it will override +the one in your application and the default from deform. + +Configuring the static resource view +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Once ``pyramid_deform`` has been included in some fashion in your application, +it will register a add a static view for Deform resources. By default, this +static view is configured via +:meth:`pyramid.config.Configurator.add_static_view` with a ``name`` of +``static-deform``. You can customise this by setting the option +``pyramid_deform.static_path`` within your Pyramid configuration. + +This option is to be a string representing an application-relative local URL +prefix for these static resources. It may alternately be a full URL. + FormView Usage -------------- @@ -93,19 +139,76 @@ You can then write a ``PageEditView`` using self.request.session.flash(u"Your changes have been saved.") return HTTPFound(location=self.request.path_url) + def save_failure(self, e): + self.request.session.flash(u"You form input was not correct.") + return self.failure(e) + def appstruct(self): context = self.request.context return {'title': context.title, 'description': context.description, 'body': context.body} -Note that ``save_success`` is only called when the form input -validates. E.g. it's not called when the ``title`` is left blank, as -it's a required field. + +Form input and validation +^^^^^^^^^^^^^^^^^^^^^^^^^ + +When a request is received from the above view, the ``FormView`` callable +determines what should happen with the request. The incoming request is +validated against the given form (and thus the schema), and depending on the +button that the user pressed to submit the form, and whether the form input validates or not a certain instance method will be called. This given method +will be named either ``buttonID_success`` or ``buttonID_failure``, where +``buttonID`` is the identifier of the button pressed and the suffix is +determined on whether the form validated or not. + +Considering the above example: + +* If the form validator succeeds, then ``save_success`` will be called with + the correct ``appstruct`` as the first argument. In the example above, this + method is not called when the ``title`` is left blank, as it is a required + field. + + Such a method *must* be specified for any given button on the form, if + any others were specified as part of ``buttons``. + +* If the form validator fails, then ``save_failure`` will be called with the + resulting ``deform.exception.ValidationFailure`` as the first argument. + + If such a named method was not present on the given ``FormView``-derived + class, then the default action is to call the ``failure(e)`` method, + which re-renders the given form with appropriate error messages. + +In either ``*_success`` or ``*_failure`` methods, you may find yourself +wanting to re-render the whole ``FormView`` and return this as a response. For +instance, you may encounter the situation where a user successfully submits a +form, and you want to immediately re-render the given form (rather than sending +a HTTP redirect, for instance). Likewise, you may like to customise the +response within the given method before it is sent back to the user (for +instance, to display custom messages or do anything else). + +This would typically just involve calling ``self()`` from within a +``save_success`` (or similar) method and returning the response. However, this +call method checks for buttons present within the request, so you will end up +with infinite recursion as the call method calls the button handler and so +forth. You can break this cycle by telling ``FormView``'s default call method +to ignore button handlers like so:: + + def save_success(self, appstruct): + response = self(ignore_buttons=True) + #Do some fancy processing or add things to response + response['my_variable'] = 'dummy' + return response + + +Appstruct +^^^^^^^^^ We use the ``appstruct`` method to pre-fill the form with values from the page object that we edit (i.e. ``context``). +Form options +^^^^^^^^^^^^ + We also provide a ``form_options`` two-tuple -- this structure can contain any options to be passed as keyword arguments to the form class' ``__init__`` method. In the case above, we customise the ID for the form using the @@ -113,6 +216,9 @@ method. In the case above, we customise the ID for the form using the and more. For more details, see http://deform.readthedocs.org/en/latest/api.html#deform.Form. +View registration +^^^^^^^^^^^^^^^^^ + The ``PageEditView`` is registered like any other Pyramid view. Maybe like this: @@ -128,6 +234,9 @@ like this: renderer='myapp:templates/form.pt', ) +Templating +^^^^^^^^^^ + Your template in ``myapp:templates/form.pt`` will receive ``form`` as a variable: this is the rendered form. Your template might look something like this:: @@ -156,6 +265,7 @@ something like this:: Deferred Colander Schemas ------------------------- + ``pyramid_deform.FormView`` will `bind `_ the schema by default to the pyramid request. You may wish to bind additional data @@ -175,20 +285,39 @@ subclass, like this:: }) return data -Wizard ------- - -XXX CSRF Schema ----------- -:: +This schema can be used as a base class in order to protect forms from +`Cross-Site Request Forgery (CSRF) +`_ attacks. In +essence, a CSRF is an attack method in which a third party can instruct a +user's browser to execute commands on a target application or site. This can +happen if a user is logged in on your application in one window or tab, and a +malicious third party site instructs the user's browser to submit a form or +action. Without protection, as the user is authenticated already (likely via +cookie), the action will succeed. + +In order to protect against this, this package provides a Colander base +schema :class:`pyramid_deform.CSRFSchema`. Use the base schema like so to add a CSRF token field to your given schema:: >>> class LoginSchema(CSRFSchema): >>> pass >>> schema = LoginSchema.get_schema(self.request) +When the schema is rendered as part of a :class:`deform.Form`, a CSRF token +(generated using the current ``Pyramid`` ``request.session.get_csrf_token()`` +method) will be included, and this token must be received and verified in the +resulting user form submission for the request to be valid. Without the token, +the request will fail. As this token is tied to your current session on your +Pyramid application, and generated per-user session, it is almost certain +(short of packet sniffing or other data theft) that an attacker will not have +this token. Thus CSRF attacks are prevented for forms using this schema. + +To prevent CSRF attacks across your application, all public-facing forms +should use schemas incorporating this protection. + SessionFileUploadTempStore -------------------------- @@ -231,6 +360,13 @@ of garbage. The tempstore doesn't clean up after itself. You'll need to set up a cron job or equivalent to delete files older than a day or so from that directory. +Wizard +------ + +This package provides a multi-step (multi-schema) form view in +:class:`pyramid_deform.FormWizardView`. Further docuemntation is coming +shortly. + Reporting Bugs / Development Versions ------------------------------------- diff --git a/pyramid_deform/__init__.py b/pyramid_deform/__init__.py index 8144088..de799e7 100644 --- a/pyramid_deform/__init__.py +++ b/pyramid_deform/__init__.py @@ -73,46 +73,65 @@ def get_bind_data(self): """ return {'request': self.request} - def __call__(self): - """ - Prepares and render the form according to provided options. + def prepare_form(self): + """ + Prepares the form object according to the provided options. + + This method, in addition to instantiating an instance of the + given :attr:``form_class``, will process the form by calling + :meth:`before`. Returns an instance of the :attr:`form_class`. + """ + use_ajax = getattr(self, 'use_ajax', False) + ajax_options = getattr(self, 'ajax_options', '{}') + self.schema = self.schema.bind(**self.get_bind_data()) + form = self.form_class(self.schema, buttons=self.buttons, + use_ajax=use_ajax, ajax_options=ajax_options, + **dict(self.form_options)) + self.before(form) + return form + + def __call__(self, ignore_buttons=False): + """ + Prepares and renders the form according to provided options. Upon receiving a ``POST`` request, this method will validate - the request against the form instance. After validation, + the request against the form instance. After validation, this calls a method based upon the name of the button used for form submission and whether the validation succeeded or failed. If the button was named ``save``, then :meth:`save_success` will be called on successful validation or :meth:`save_failure` will be called upon failure. An exception to this is when no such ``save_failure`` method is present; in this case, the fallback - is :meth:`failure``. - + is :meth:`failure``. + + This button method call behaviour can be avoided by setting the + ``ignore_buttons`` argument to this method to be ``True``. This is + particularly useful when needing to return a response (eg a rendered + form) from a button ``success`` or ``failure`` method. Without + this option, attempting to re-render the form view from one of these + methods will result in infinite recursion as the method calls itself. + Returns a ``dict`` structure suitable for provision tog the given - view. By default, this is the page template specified + view. By default, this is the page template specified. """ - use_ajax = getattr(self, 'use_ajax', False) - ajax_options = getattr(self, 'ajax_options', '{}') - self.schema = self.schema.bind(**self.get_bind_data()) - form = self.form_class(self.schema, buttons=self.buttons, - use_ajax=use_ajax, ajax_options=ajax_options, - **dict(self.form_options)) - self.before(form) + form = self.prepare_form() reqts = form.get_widget_resources() result = None - for button in form.buttons: - if button.name in self.request.POST: - success_method = getattr(self, '%s_success' % button.name) - try: - controls = self.request.POST.items() - validated = form.validate(controls) - result = success_method(validated) - except deform.exception.ValidationFailure as e: - fail = getattr(self, '%s_failure' % button.name, None) - if fail is None: - fail = self.failure - result = fail(e) - break + if not ignore_buttons: + for button in form.buttons: + if self.request.POST.get(button.name): + success_method = getattr(self, '%s_success' % button.name) + try: + controls = self.request.POST.items() + validated = form.validate(controls) + result = success_method(validated) + except deform.exception.ValidationFailure as e: + fail = getattr(self, '%s_failure' % button.name, None) + if fail is None: + fail = self.failure + result = fail(e) + break if result is None: result = self.show(form) @@ -130,8 +149,8 @@ def before(self, form): By default, this method does nothing. Override this method in your derived class to modify the ``form``. Your function will be executed immediately after instansiating the form - instance in :meth:`__call__` (thus before obtaining widget resources, - considering buttons, or rendering). + instance in :meth:`prepare_form` (thus before obtaining widget + resources, considering buttons, or rendering during :meth:`__call__`). """ pass @@ -409,10 +428,10 @@ class CSRFSchema(colander.Schema): class MySchema(CSRFSchema): my_value = colander.SchemaNode(colander.String()) - And in your application code, *bind* the schema, passing the request - as a keyword argument: + And in your application code, *bind* the schema, passing the request + as a keyword argument: - .. code-block:: python + .. code-block:: python def aview(request): schema = MySchema().bind(request=request) @@ -526,8 +545,8 @@ def includeme(config): Currently, this hook will set up and register translation paths for Deform and Colander, add a static view for Deform resources (uses ``pyramid_deform.static_path`` from the Pyramid configuration if - specified else ``static-deform`` by default), and configures a - template search path (if one is specified by + specified, otherwise ``static-deform`` by default), and + configures one or more template search paths (if specified by ``pyramid_deform.template_search_path`` in your Pyramid configuration). """ diff --git a/pyramid_deform/tests.py b/pyramid_deform/tests.py index a1a79c9..11ffa3b 100644 --- a/pyramid_deform/tests.py +++ b/pyramid_deform/tests.py @@ -59,6 +59,21 @@ def test___call__show_result_response(self): result = inst() self.assertEqual(result, response) + def test__call__ignore_buttons(self): + schema = DummySchema() + request = DummyRequest() + request.POST['submit'] = True + inst = self._makeOne(request) + inst.schema = schema + inst.buttons = (DummyButton('submit'), ) + inst.submit_success = lambda *x: 'success' + inst.form_class = DummyForm + result = inst(ignore_buttons=True) + #Result should not be success - button handler is ignored + self.assertEqual( + result, + {'css_links': (), 'js_links': (), 'form': 'rendered with None'}) + def test___call__button_in_request(self): schema = DummySchema() request = DummyRequest() @@ -142,6 +157,23 @@ def check_form(self, form): for key, value in dict(form_options).items(): self.assertEqual(getattr(form, key), value) + def test_prepare_form(self): + schema = DummySchema() + request = DummyRequest() + inst = self._makeOne(request) + inst.schema = schema + inst.form_class = DummyForm + + def manipulate_form(self, form): + form.custom_attr = 'custom-attr' + + inst.before = types.MethodType(manipulate_form, inst) + form = inst.prepare_form() + + #Form should be correct type and customised according to options + self.assertTrue(isinstance(form, DummyForm)) + self.assertEqual(form.custom_attr, 'custom-attr') + class TestFormWizardView(unittest.TestCase): def _makeOne(self, wizard): from pyramid_deform import FormWizardView