├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── NEWS.txt ├── README.rst ├── bootstrap.py ├── buildout.cfg ├── docs ├── Makefile └── source │ ├── api │ ├── sessionwizard.rst │ ├── step.rst │ └── wizardstate.rst │ ├── conf.py │ ├── index.rst │ └── sessionwizard.rst ├── requirements └── project.txt ├── setup.py └── src └── merlin ├── __init__.py ├── tests ├── __init__.py ├── fixtures │ ├── __init__.py │ └── testproject │ │ ├── __init__.py │ │ ├── forms.py │ │ ├── manage.py │ │ ├── settings.py │ │ ├── templates │ │ └── forms │ │ │ ├── base.html │ │ │ ├── social_wizard.html │ │ │ └── wizard.html │ │ ├── urls.py │ │ ├── views.py │ │ └── wizard.py ├── test_session_wizard.py └── test_utils.py └── wizards ├── __init__.py ├── session.py └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.egg-info 3 | 4 | .coverage 5 | .DS_Store 6 | .installed.cfg 7 | 8 | .idea/* 9 | 10 | bin 11 | build 12 | develop-eggs 13 | dist 14 | downloads 15 | eggs 16 | parts 17 | tmp 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | * Redistributions in binary 10 | form must reproduce the above copyright notice, this list of conditions and the 11 | following disclaimer in the documentation and/or other materials provided with 12 | the distribution. 13 | * Neither the name of the nor the names of its 14 | contributors may be used to endorse or promote products derived from this 15 | software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 21 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 22 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 23 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 25 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include NEWS.txt 3 | include LICENSE.txt 4 | -------------------------------------------------------------------------------- /NEWS.txt: -------------------------------------------------------------------------------- 1 | .. This is your project NEWS file which will contain the release notes. 2 | .. Example: http://www.python.org/download/releases/2.6/NEWS.txt 3 | .. The content of this file, along with README.rst, will appear in your 4 | .. project's PyPI page. 5 | 6 | News 7 | ==== 8 | 9 | 0.8 10 | --- 11 | 12 | *Release date: 06-Feb-2012* 13 | 14 | * Adding a pip requirements file for those using virtualenv 15 | 16 | 0.7 17 | --- 18 | 19 | *Release date: 06-Feb-2012* 20 | 21 | * Updated the dependancies for the project so the buildout build would 22 | work again 23 | * Stopped squashing missing slug and step errors with a 404 and now 24 | raising custom exceptions 25 | 26 | 0.6 27 | --- 28 | 29 | *Release date: 28-Nov-2011* 30 | 31 | * Merged a Pull Request to make sure the slug matching in the url matches the 32 | highest index. 33 | 34 | 0.5 35 | --- 36 | 37 | *Release date: 25-Nov-2010* 38 | 39 | * Merged a Pull Request to allow the SessionWizard to support Steps that use a 40 | ModelForm instead of just a Form 41 | * Added the ability to cancel a wizard! 42 | 43 | 0.4 44 | --- 45 | 46 | *Release date: 30-Nov-2010* 47 | 48 | * Fixed the bug where existing form data was not getting populated on a GET request. 49 | * Updated the process of process_POST to not automatically call self.clear(). 50 | The developer of the wizard now should call self.clear() right before a 51 | successful return from self.done(). If the wizard needs to redirect to a step 52 | in the process because of post processing error then the SessionWizard will 53 | not remove the data form session now. 54 | 55 | 0.3 56 | --- 57 | 58 | *Release date: 30-Nov-2010* 59 | 60 | * Changed the way the id of the SessionWizard is generated to address problem of 61 | lose of data in a multi-threaded/multi-process Django environment 62 | 63 | 64 | 0.2 65 | --- 66 | 67 | *Release date: 3-Nov-2010* 68 | 69 | * Added the initialize hook to the SessionWizard to allow per request initialization. 70 | 71 | 72 | 0.1 73 | --- 74 | 75 | *Release date: 24-Aug-2010* 76 | 77 | * Initial project creation finished. 78 | 79 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Merlin 2 | ============= 3 | 4 | What's this for 5 | --------------- 6 | 7 | The Django FormWizard was not exactly what we were looking for so we decided to 8 | scratch our own itch and create a project that would host different types 9 | of form wizards for use with Django. Currently we have a SessionWizard, which is 10 | a form wizard that is backed by the Django session object. This wizard provides 11 | the ability to use the SessionWizard as a callable in the URLConf but still 12 | provide thread safety. 13 | 14 | Besides the storage of SessionWizard state being in session, it uses an HTTP 15 | GET to render a form and a POST to process a form. This differs from the 16 | Django FormWizard which uses POST for everything. One benefit of this is the 17 | ability to got to previous steps in the wizard. 18 | 19 | Documentation and examples can be found at: http://packages.python.org/django-merlin/ 20 | 21 | Installation 22 | ------------ 23 | 24 | You need Django for this to work, if you need help with that `head here 25 | `_ 26 | 27 | Using Pip:: 28 | 29 | pip install django-merlin 30 | 31 | Credits 32 | ------- 33 | 34 | This was mostly inspired by the Django form wizard and the SessionWizard snippet 35 | located `here `_ 36 | -------------------------------------------------------------------------------- /bootstrap.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2006 Zope Corporation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE. 12 | # 13 | ############################################################################## 14 | """Bootstrap a buildout-based project 15 | 16 | Simply run this script in a directory containing a buildout.cfg. 17 | The script accepts buildout command-line options, so you can 18 | use the -c option to specify an alternate configuration file. 19 | 20 | $Id: bootstrap.py 102545 2009-08-06 14:49:47Z chrisw $ 21 | """ 22 | 23 | import os, shutil, sys, tempfile, urllib2 24 | from optparse import OptionParser 25 | 26 | tmpeggs = tempfile.mkdtemp() 27 | 28 | is_jython = sys.platform.startswith('java') 29 | 30 | # parsing arguments 31 | parser = OptionParser() 32 | parser.add_option("-v", "--version", dest="version", 33 | help="use a specific zc.buildout version") 34 | parser.add_option("-d", "--distribute", 35 | action="store_true", dest="distribute", default=True, 36 | help="Use Disribute rather than Setuptools.") 37 | 38 | options, args = parser.parse_args() 39 | 40 | if options.version is not None: 41 | VERSION = '==%s' % options.version 42 | else: 43 | VERSION = '' 44 | 45 | USE_DISTRIBUTE = options.distribute 46 | args = args + ['bootstrap'] 47 | 48 | to_reload = False 49 | try: 50 | import pkg_resources 51 | if not hasattr(pkg_resources, '_distribute'): 52 | to_reload = True 53 | raise ImportError 54 | except ImportError: 55 | ez = {} 56 | if USE_DISTRIBUTE: 57 | exec urllib2.urlopen('http://python-distribute.org/distribute_setup.py' 58 | ).read() in ez 59 | ez['use_setuptools'](to_dir=tmpeggs, download_delay=0, no_fake=True) 60 | else: 61 | exec urllib2.urlopen('http://peak.telecommunity.com/dist/ez_setup.py' 62 | ).read() in ez 63 | ez['use_setuptools'](to_dir=tmpeggs, download_delay=0) 64 | 65 | if to_reload: 66 | reload(pkg_resources) 67 | else: 68 | import pkg_resources 69 | 70 | if sys.platform == 'win32': 71 | def quote(c): 72 | if ' ' in c: 73 | return '"%s"' % c # work around spawn lamosity on windows 74 | else: 75 | return c 76 | else: 77 | def quote (c): 78 | return c 79 | 80 | cmd = 'from setuptools.command.easy_install import main; main()' 81 | ws = pkg_resources.working_set 82 | 83 | if USE_DISTRIBUTE: 84 | requirement = 'distribute' 85 | else: 86 | requirement = 'setuptools' 87 | 88 | if is_jython: 89 | import subprocess 90 | 91 | assert subprocess.Popen([sys.executable] + ['-c', quote(cmd), '-mqNxd', 92 | quote(tmpeggs), 'zc.buildout' + VERSION], 93 | env=dict(os.environ, 94 | PYTHONPATH= 95 | ws.find(pkg_resources.Requirement.parse(requirement)).location 96 | ), 97 | ).wait() == 0 98 | 99 | else: 100 | assert os.spawnle( 101 | os.P_WAIT, sys.executable, quote (sys.executable), 102 | '-c', quote (cmd), '-mqNxd', quote (tmpeggs), 'zc.buildout' + VERSION, 103 | dict(os.environ, 104 | PYTHONPATH= 105 | ws.find(pkg_resources.Requirement.parse(requirement)).location 106 | ), 107 | ) == 0 108 | 109 | ws.add_entry(tmpeggs) 110 | ws.require('zc.buildout' + VERSION) 111 | import zc.buildout.buildout 112 | zc.buildout.buildout.main(args) 113 | shutil.rmtree(tmpeggs) 114 | -------------------------------------------------------------------------------- /buildout.cfg: -------------------------------------------------------------------------------- 1 | [buildout] 2 | allow-picked-versions = false 3 | develop = . 4 | versions = versions 5 | 6 | parts = 7 | python 8 | scripts 9 | sphinx 10 | test 11 | 12 | eggs = 13 | django-merlin 14 | 15 | [python] 16 | recipe = zc.recipe.egg 17 | interpreter = python 18 | eggs = ${buildout:eggs} 19 | 20 | [scripts] 21 | recipe = zc.recipe.egg:scripts 22 | eggs = ${buildout:eggs} 23 | 24 | [sphinx] 25 | recipe = zc.recipe.egg 26 | eggs = ${buildout:eggs} 27 | django 28 | Sphinx 29 | 30 | [test] 31 | recipe = pbp.recipe.noserunner 32 | working-directory = src 33 | 34 | eggs = ${buildout:eggs} 35 | BeautifulSoup 36 | coverage 37 | django 38 | 39 | defaults = 40 | --with-coverage 41 | --cover-package=merlin 42 | --nocapture 43 | --nologcapture 44 | --logging-clear-handlers 45 | --verbosity=1 46 | 47 | [versions] 48 | BeautifulSoup = 3.2.0 49 | coverage = 3.5.1 50 | distribute = 0.6.24 51 | django = 1.3.1 52 | docutils = 0.8.1 53 | Jinja2 = 2.6 54 | nose = 1.1.2 55 | pbp.recipe.noserunner = 0.2.6 56 | Pygments = 1.4 57 | Sphinx = 1.1.2 58 | zc.buildout = 1.5.2 59 | zc.recipe.egg = 1.3.2 60 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = ../bin/sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 14 | 15 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " singlehtml to make a single large HTML file" 22 | @echo " pickle to make pickle files" 23 | @echo " json to make JSON files" 24 | @echo " htmlhelp to make HTML files and a HTML help project" 25 | @echo " qthelp to make HTML files and a qthelp project" 26 | @echo " devhelp to make HTML files and a Devhelp project" 27 | @echo " epub to make an epub" 28 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 29 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 30 | @echo " text to make text files" 31 | @echo " man to make manual pages" 32 | @echo " changes to make an overview of all changed/added/deprecated items" 33 | @echo " linkcheck to check all external links for integrity" 34 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 35 | 36 | clean: 37 | -rm -rf $(BUILDDIR)/* 38 | 39 | html: 40 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 41 | @echo 42 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 43 | 44 | dirhtml: 45 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 48 | 49 | singlehtml: 50 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 51 | @echo 52 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 53 | 54 | pickle: 55 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 56 | @echo 57 | @echo "Build finished; now you can process the pickle files." 58 | 59 | json: 60 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 61 | @echo 62 | @echo "Build finished; now you can process the JSON files." 63 | 64 | htmlhelp: 65 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 66 | @echo 67 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 68 | ".hhp project file in $(BUILDDIR)/htmlhelp." 69 | 70 | qthelp: 71 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 72 | @echo 73 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 74 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 75 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-merlin.qhcp" 76 | @echo "To view the help file:" 77 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-merlin.qhc" 78 | 79 | devhelp: 80 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 81 | @echo 82 | @echo "Build finished." 83 | @echo "To view the help file:" 84 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-merlin" 85 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-merlin" 86 | @echo "# devhelp" 87 | 88 | epub: 89 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 90 | @echo 91 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 92 | 93 | latex: 94 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 95 | @echo 96 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 97 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 98 | "(use \`make latexpdf' here to do that automatically)." 99 | 100 | latexpdf: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo "Running LaTeX files through pdflatex..." 103 | make -C $(BUILDDIR)/latex all-pdf 104 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 105 | 106 | text: 107 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 108 | @echo 109 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 110 | 111 | man: 112 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 113 | @echo 114 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 115 | 116 | changes: 117 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 118 | @echo 119 | @echo "The overview file is in $(BUILDDIR)/changes." 120 | 121 | linkcheck: 122 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 123 | @echo 124 | @echo "Link check complete; look for any errors in the above output " \ 125 | "or in $(BUILDDIR)/linkcheck/output.txt." 126 | 127 | doctest: 128 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 129 | @echo "Testing of doctests in the sources finished, look at the " \ 130 | "results in $(BUILDDIR)/doctest/output.txt." 131 | -------------------------------------------------------------------------------- /docs/source/api/sessionwizard.rst: -------------------------------------------------------------------------------- 1 | .. _api_sessionwizard: 2 | 3 | ============= 4 | SessionWizard 5 | ============= 6 | 7 | .. autoclass:: merlin.wizards.session.SessionWizard 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/source/api/step.rst: -------------------------------------------------------------------------------- 1 | .. _api_step: 2 | 3 | ==== 4 | Step 5 | ==== 6 | 7 | .. autoclass:: merlin.wizards.utils.Step 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/source/api/wizardstate.rst: -------------------------------------------------------------------------------- 1 | .. _api_wizardstate: 2 | 3 | =========== 4 | WizardState 5 | =========== 6 | 7 | .. autoclass:: merlin.wizards.utils.WizardState 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-merlin documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Aug 24 08:31:05 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'sphinx.ext.viewcode'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'django-merlin' 44 | copyright = u'2010, Travis Chase, Chad Gallemore' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '0.8' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '0.8' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = [] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. See the documentation for 93 | # a list of builtin themes. 94 | html_theme = 'sphinxdoc' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_domain_indices = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 153 | #html_show_sphinx = True 154 | 155 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 156 | #html_show_copyright = True 157 | 158 | # If true, an OpenSearch description file will be output, and all pages will 159 | # contain a tag referring to it. The value of this option must be the 160 | # base URL from which the finished HTML is served. 161 | #html_use_opensearch = '' 162 | 163 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 164 | #html_file_suffix = None 165 | 166 | # Output file base name for HTML help builder. 167 | htmlhelp_basename = 'django-merlindoc' 168 | 169 | 170 | # -- Options for LaTeX output -------------------------------------------------- 171 | 172 | # The paper size ('letter' or 'a4'). 173 | #latex_paper_size = 'letter' 174 | 175 | # The font size ('10pt', '11pt' or '12pt'). 176 | #latex_font_size = '10pt' 177 | 178 | # Grouping the document tree into LaTeX files. List of tuples 179 | # (source start file, target name, title, author, documentclass [howto/manual]). 180 | latex_documents = [ 181 | ('index', 'django-merlin.tex', u'django-merlin Documentation', 182 | u'Travis Chase, Chad Gallemore', 'manual'), 183 | ] 184 | 185 | # The name of an image file (relative to this directory) to place at the top of 186 | # the title page. 187 | #latex_logo = None 188 | 189 | # For "manual" documents, if this is true, then toplevel headings are parts, 190 | # not chapters. 191 | #latex_use_parts = False 192 | 193 | # If true, show page references after internal links. 194 | #latex_show_pagerefs = False 195 | 196 | # If true, show URL addresses after external links. 197 | #latex_show_urls = False 198 | 199 | # Additional stuff for the LaTeX preamble. 200 | #latex_preamble = '' 201 | 202 | # Documents to append as an appendix to all manuals. 203 | #latex_appendices = [] 204 | 205 | # If false, no module index is generated. 206 | #latex_domain_indices = True 207 | 208 | 209 | # -- Options for manual page output -------------------------------------------- 210 | 211 | # One entry per manual page. List of tuples 212 | # (source start file, name, description, authors, manual section). 213 | man_pages = [ 214 | ('index', 'django-merlin', u'django-merlin Documentation', 215 | [u'Travis Chase, Chad Gallemore'], 1) 216 | ] 217 | 218 | 219 | # -- Options for Epub output --------------------------------------------------- 220 | 221 | # Bibliographic Dublin Core info. 222 | epub_title = u'django-merlin' 223 | epub_author = u'Travis Chase, Chad Gallemore' 224 | epub_publisher = u'Travis Chase, Chad Gallemore' 225 | epub_copyright = u'2010, Travis Chase, Chad Gallemore' 226 | 227 | # The language of the text. It defaults to the language option 228 | # or en if the language is not set. 229 | #epub_language = '' 230 | 231 | # The scheme of the identifier. Typical schemes are ISBN or URL. 232 | #epub_scheme = '' 233 | 234 | # The unique identifier of the text. This can be a ISBN number 235 | # or the project homepage. 236 | #epub_identifier = '' 237 | 238 | # A unique identification for the text. 239 | #epub_uid = '' 240 | 241 | # HTML files that should be inserted before the pages created by sphinx. 242 | # The format is a list of tuples containing the path and title. 243 | #epub_pre_files = [] 244 | 245 | # HTML files shat should be inserted after the pages created by sphinx. 246 | # The format is a list of tuples containing the path and title. 247 | #epub_post_files = [] 248 | 249 | # A list of files that should not be packed into the epub file. 250 | #epub_exclude_files = [] 251 | 252 | # The depth of the table of contents in toc.ncx. 253 | #epub_tocdepth = 3 254 | 255 | # Allow duplicate toc entries. 256 | #epub_tocdup = True 257 | 258 | 259 | from django.core.management import setup_environ 260 | 261 | from merlin.tests.fixtures.testproject import settings 262 | 263 | setup_environ(settings) 264 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. django-merlin documentation master file, created by 2 | sphinx-quickstart on Tue Aug 24 08:31:05 2010. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-merlin's documentation! 7 | ========================================= 8 | 9 | Getting started 10 | --------------- 11 | 12 | .. toctree:: 13 | :maxdepth: 2 14 | 15 | sessionwizard 16 | 17 | API documentation 18 | ----------------- 19 | 20 | .. toctree:: 21 | :maxdepth: 2 22 | 23 | api/sessionwizard 24 | api/step 25 | api/wizardstate 26 | 27 | Indices and tables 28 | ================== 29 | 30 | * :ref:`genindex` 31 | * :ref:`modindex` 32 | * :ref:`search` 33 | 34 | -------------------------------------------------------------------------------- /docs/source/sessionwizard.rst: -------------------------------------------------------------------------------- 1 | .. _sessionwizard: 2 | 3 | ============== 4 | Session Wizard 5 | ============== 6 | 7 | Django comes with an optional "form wizard" application that allows you to 8 | split forms across multiple web pages in a sequential order. This ability is 9 | provided by using the :class:`~django.contrib.formtools.wizard.FormWizard` 10 | class. You would use this when you have, for example, a long registration 11 | process that needs to be split up in small digestable chunks, making it 12 | easier on your users to complete the process. 13 | 14 | You can see the :class:`~django.contrib.formtools.wizard.FormWizard` 15 | documentation at: 16 | http://docs.djangoproject.com/en/dev/ref/contrib/formtools/form-wizard/ 17 | 18 | Is there a need for a different one? 19 | ==================================== 20 | 21 | In a word, yes. A few things the ``FormWizard`` does that may not work for your 22 | projects, as it did not for ours. First, the ``FormWizard`` using an HTTP 23 | ``POST`` to process a form. This makes it tough when you are trying to use 24 | the browser's back button to change some data on a previous step. The 25 | ``FormWizard`` checks for any ``GET`` requests and moves you to the first 26 | step in the wizard process, YUCK! Secondly, the ``FormWizard`` docs recommends 27 | using your wizard subclass as the callable in a ``urlconf`` in your urls.py. 28 | This is a really nice feature except that it will only create one copy of 29 | your ``FormWizard`` for all requests. This works well until you start messing 30 | with the hooks it provides to inserting or removing steps based on data 31 | from a form submission. Once you insert or remove a form, the steps are now 32 | changed for any subsequent users. 33 | 34 | How is ``SessionWizard`` different? 35 | =================================== 36 | 37 | 1. The :ref:`SessionWizard ` is given a list of 38 | :ref:`Step ` objects instead of a list of Django ``Form`` 39 | classes. 40 | 2. :ref:`SessionWizard ` stores all of its state in the 41 | Django ``Session`` object. This allows you to use the ``SessionWizard`` 42 | in the ``urlconf`` and keep state seperate by user (or session). When 43 | the ``SessionWizard`` starts it makes a copy of the 44 | :ref:`Step ` list for the session so it can be manipulated 45 | independantly of any other session. 46 | 3. The :ref:`SessionWizard ` processes all ``GET`` requests 47 | as a form view and only moves to the next step in the sequence on a 48 | succesful ``POST`` request. This allows for the browser's Back button 49 | to function correctly. 50 | 4. Each :ref:`Step ` in the sequence has a unique slug for that 51 | step. This slug is used in the ``urlconf`` to be able to go to any part 52 | of the wizard. This allows you to provide proper "Back" and "Next" 53 | buttons on your forms. 54 | 55 | How to use ``SessionWizard`` 56 | ============================ 57 | 58 | Here is the basic workflow needed to use the ``SessionWizard`` object: 59 | 60 | 1. Make sure you have enabled the Django session middleware. 61 | 2. Create a subclass the ``SessionWizard`` class and override the 62 | :meth:`~SessionWizard.done()` method. The :meth:`~SessionWizard.done()` 63 | method allows you to collect all of the validated form data, process 64 | that data and move on to the next web page after successful processing 65 | of the wizard. You are able to redirect out of done if there are some 66 | post processing errors you need the user to be notified of. If you have 67 | processed everything correctly then you can call the 68 | :meth:`~SessionWizard.clear()` method to clean up the data stored in 69 | the session. If :meth:`~SessionWizard.clear()` is not called then the 70 | next time the same session goes through the wizard the existing form 71 | data from the original run will be put into the forms. 72 | 3. Override the :meth:`~SessionWizard.get_template()` method to return the 73 | path to the template the forms should use. The default is to return 74 | "forms/wizard.html", which you provide. Based on the step passed in 75 | you could return different templates for different forms. 76 | 4. Create a url that will be the entry point of your wizard. This url should 77 | provide a ``(?P[A-Za-z0-9_-]+)`` option in the url pattern. 78 | 5. Point this url to the subclass of ``SessionWizard``, providing a ``list`` 79 | of :ref:`Step ` objects that the wizard should process in the 80 | order it should process them. 81 | 6. Sit back and enjoy form wizard goodness! 82 | 83 | How it works 84 | ============ 85 | 86 | 1. The user makes a ``GET`` request to your wizard url with the first 87 | slug of the sequence. 88 | 2. The wizard returns the form using the template you specify. 89 | 3. The user submits the form using a ``POST`` request. 90 | 4. The wizard validates the form data. If the data is invalid it returns the 91 | user to the current form and you can display to the user any errors 92 | that have occured. If the data is valid then the wizard stores the 93 | clean data in its state object. 94 | 5. If there is another step in the process the wizard sends a redirect to 95 | the user to the next step in the sequence. If not next step is found 96 | the wizard then calls the :meth:`~SessionWizard.done()` method, which 97 | expects to return some ``HttpResponse`` to the user letting them know 98 | they are finished with the process. 99 | 100 | Creating templates for the forms 101 | ================================ 102 | 103 | You'll need to create a template that renders the step's form. By 104 | default, every form uses a template called :file:`forms/wizard.html`. (You can 105 | change this template name by overriding :meth:`~SessionWizard.get_template()`) 106 | 107 | The template recieves the following context: 108 | 109 | * ``current_step`` -- The current :ref:`Step ` being processed 110 | * ``form``-- The current form for the current step (with any data already 111 | available) 112 | * ``previous_step`` -- The previous :ref:`Step ` or ``None`` 113 | * ``next_step`` -- The next :ref:`Step ` or ``None`` 114 | * ``url_base`` -- The base URL that can be used in creating links to the 115 | next for previous steps 116 | * ``extra_context`` -- Any extra context you have provided using 117 | overriding the :meth:`~SessionWizard.process_show_form()` method 118 | 119 | A couple of goodies 120 | =================== 121 | 122 | There are couple of hooks in the ``SessionWizard`` that allow you to modify the 123 | execution of the wizard in interesting ways. For more in depth information make 124 | sure to check out the API docs for :ref:`SessionWizard `. 125 | 126 | * :meth:`~SessionWizard.process_show_form()` -- allows you to provide any 127 | extra context data that needs to be provided to the template for 128 | processing 129 | * :meth:`~SessionWizard.process_step()` -- allows for changing the internal 130 | state of the wizard. For example, you could use this hook to add or remove 131 | steps in the process based off some user submitted information. You can 132 | use the methods :meth:`~SessionWizard.remove_step()`, 133 | :meth:`~SessionWizard.insert_before()` and 134 | :meth:`~SessionWizard.insert_after()` to accomplish this. 135 | * :meth:`~SessionWizard.get_template()` -- allows you to return a template 136 | path to use for processing the currently executing step. 137 | * :meth:`~SessionWizard.render_form()` -- allows you the ability to render 138 | the form however you see fit. The default is to use the 139 | ``render_to_response`` Django shortcut; but, you could use this hook 140 | to provide a :class:`PageAssembly` render method from the excellent 141 | django-crunchyfrog project found at : 142 | http://github.com/localbase/django-crunchyfrog 143 | * :meth:`~SessionWizard.initialize()` -- allows you the ability to 144 | initialize the wizard at each request. This can be used to put data into 145 | the wizard state object that can then be used in the 146 | :meth:`~SessionWizard.done()` method. 147 | 148 | 149 | I am tired, can't I just cancel this wizard? 150 | ============================================ 151 | 152 | When you have a long form process and the user decides they don't want to 153 | finish the wizard you would to provide a Cancel button or link they can 154 | click that will reset the wizard and redirect the user to a different screen. 155 | It would be great if the `SessionWizard` provided a way to handle this and 156 | also clean up the data it has been tracking as well. Well pine no more because 157 | the `SessionWizard` has got your back! 158 | 159 | When you want to cancel a wizard you can just pass "cancel" as the step slug in 160 | the url. By just doing this the wizard will, by default, clear the session 161 | data it was tracking and send an `HttpResponseRedirect` to the / url. You can 162 | provide the query string parameter ?rd=yoururl to redirect to a different url. 163 | If you have a `Step` with the slug of "cancel" then the wizard will proceed to 164 | this step and you will have to handle the cancel action yourself. 165 | 166 | For example, let's say we have a wizard and url /mywizard and we have steps 167 | "form1" and "form2". 168 | 169 | 1. The user sends a GET request to /mywizard/form1. 170 | 2. The user fills out the form information and clicks the Next button. 171 | 3. The browser sends a POST request with the form data and the wizard 172 | does its tricks and redirects the user to /mywizard/form2. 173 | 4. The user is sleepy and decides to come back tomorrow and finish the 174 | wizard. The user then clicks the cancel link you have provided in 175 | the template. 176 | 5. The cancel link in your template points to /mywizard/cancel?rd=/thanks. 177 | 6. The browser sends a GET request to /mywizard/cancel?rd=/thanks and the 178 | `SessionWizard` sees it has no step called "cancel". 179 | 7. The `SessionWizard` calls its internal cancel method, which cleans up 180 | any session and form data the wizard was tracking, and redirects the 181 | user to /thanks! 182 | 8. No harm, no foul. 183 | 184 | 185 | * :meth:`~SessionWizard.cancel()` -- cleans up the session data that has 186 | been tracked by the wizard. You can override this method and provide 187 | other features you would like when cancelling, for example; You could 188 | track the cancel actions from wizards. 189 | 190 | Enjoy! 191 | ====== 192 | 193 | We are always looking for updates to make ``SessionWizard`` even better and 194 | provide even more form wizards to this tool chest. If you have any questions, 195 | comments or suggestions please email us at development@localbase.com. You can 196 | always particapte by using the projects GitHub account as well: 197 | http://github.com/localbase/django-merlin 198 | 199 | Credits 200 | ======= 201 | 202 | This was mostly inspired by the Django form wizard and the SessionWizard snippet 203 | located `here `_ 204 | -------------------------------------------------------------------------------- /requirements/project.txt: -------------------------------------------------------------------------------- 1 | Django==1.3.1 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import sys, os 3 | 4 | here = os.path.abspath(os.path.dirname(__file__)) 5 | README = open(os.path.join(here, 'README.rst')).read() 6 | NEWS = open(os.path.join(here, 'NEWS.txt')).read() 7 | 8 | 9 | version = '0.8' 10 | 11 | install_requires = [ 12 | # List your project dependencies here. 13 | # For more details, see: 14 | # http://packages.python.org/distribute/setuptools.html#declaring-dependencies 15 | ] 16 | 17 | 18 | setup(name='django-merlin', 19 | version=version, 20 | description="Providing alternate form wizards for the Django project.", 21 | long_description=README + '\n\n' + NEWS, 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'Framework :: Django', 25 | 'Intended Audience :: Developers', 26 | 'License :: OSI Approved :: BSD License', 27 | 'Natural Language :: English', 28 | 'Programming Language :: Python :: 2.7' 29 | ], 30 | keywords='forms wizard django session', 31 | author='supercodepoet', 32 | author_email='travis@travischase.me', 33 | url='http://github.com/supercodepoet/django-merlin', 34 | license='BSD', 35 | packages=find_packages('src'), 36 | package_dir = {'': 'src'}, 37 | include_package_data=True, 38 | zip_safe=False, 39 | install_requires=install_requires 40 | ) 41 | -------------------------------------------------------------------------------- /src/merlin/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supercodepoet/django-merlin/7129d58638c78dcc56a0e2f2952220b201c3afd5/src/merlin/__init__.py -------------------------------------------------------------------------------- /src/merlin/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from django.core.management import setup_environ 2 | 3 | from merlin.tests.fixtures.testproject import settings 4 | 5 | setup_environ(settings) 6 | 7 | from django.test import utils 8 | from django.db import connection 9 | 10 | def setup_package(module): 11 | utils.setup_test_environment() 12 | module._old_db_name = connection.creation.create_test_db(verbosity=1) 13 | 14 | def teardown_package(module): 15 | connection.creation.destroy_test_db(module._old_db_name) 16 | utils.teardown_test_environment() 17 | 18 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supercodepoet/django-merlin/7129d58638c78dcc56a0e2f2952220b201c3afd5/src/merlin/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/supercodepoet/django-merlin/7129d58638c78dcc56a0e2f2952220b201c3afd5/src/merlin/tests/fixtures/testproject/__init__.py -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | 3 | 4 | class UserDetailsForm(forms.Form): 5 | first_name = forms.CharField() 6 | last_name = forms.CharField() 7 | email = forms.EmailField() 8 | 9 | 10 | class ContactDetailsForm(forms.Form): 11 | street_address = forms.CharField() 12 | city = forms.CharField() 13 | state = forms.CharField() 14 | zipcode = forms.CharField() 15 | phone = forms.CharField() 16 | 17 | 18 | class FewMoreThingsForm(forms.Form): 19 | bio = forms.CharField() 20 | 21 | 22 | class SocialForm(forms.Form): 23 | twitter = forms.URLField() 24 | facebook = forms.URLField() 25 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__) 8 | sys.exit(1) 9 | 10 | if __name__ == "__main__": 11 | execute_manager(settings) 12 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@domain.com'), 8 | ) 9 | 10 | MANAGERS = ADMINS 11 | 12 | DATABASES = { 13 | 'default': { 14 | 'ENGINE': 'sqlite3', 15 | 'NAME': ':memory:' 16 | } 17 | } 18 | 19 | # Local time zone for this installation. Choices can be found here: 20 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 21 | # although not all choices may be available on all operating systems. 22 | # On Unix systems, a value of None will cause Django to use the same 23 | # timezone as the operating system. 24 | # If running in a Windows environment this must be set to the same as your 25 | # system time zone. 26 | TIME_ZONE = 'America/Chicago' 27 | 28 | # Language code for this installation. All choices can be found here: 29 | # http://www.i18nguy.com/unicode/language-identifiers.html 30 | LANGUAGE_CODE = 'en-us' 31 | 32 | SITE_ID = 1 33 | 34 | # If you set this to False, Django will make some optimizations so as not 35 | # to load the internationalization machinery. 36 | USE_I18N = True 37 | 38 | # If you set this to False, Django will not format dates, numbers and 39 | # calendars according to the current locale 40 | USE_L10N = True 41 | 42 | # Absolute path to the directory that holds media. 43 | # Example: "/home/media/media.lawrence.com/" 44 | MEDIA_ROOT = '' 45 | 46 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 47 | # trailing slash if there is a path component (optional in other cases). 48 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 49 | MEDIA_URL = '' 50 | 51 | # URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a 52 | # trailing slash. 53 | # Examples: "http://foo.com/media/", "/media/". 54 | ADMIN_MEDIA_PREFIX = '/media/' 55 | 56 | # Make this unique, and don't share it with anybody. 57 | SECRET_KEY = '!sa9otgn21%eary!^m)%kl42))0&s0^moea$)@0m7udy3-_ge$' 58 | 59 | # List of callables that know how to import templates from various sources. 60 | TEMPLATE_LOADERS = ( 61 | 'django.template.loaders.filesystem.Loader', 62 | 'django.template.loaders.app_directories.Loader', 63 | # 'django.template.loaders.eggs.Loader', 64 | ) 65 | 66 | MIDDLEWARE_CLASSES = ( 67 | 'django.middleware.common.CommonMiddleware', 68 | 'django.contrib.sessions.middleware.SessionMiddleware', 69 | 'django.middleware.csrf.CsrfViewMiddleware', 70 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 71 | 'django.contrib.messages.middleware.MessageMiddleware', 72 | ) 73 | 74 | ROOT_URLCONF = 'testproject.urls' 75 | 76 | TEMPLATE_DIRS = ( 77 | os.path.join(os.path.dirname(__file__), 'templates'), 78 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 79 | # Always use forward slashes, even on Windows. 80 | # Don't forget to use absolute paths, not relative paths. 81 | ) 82 | 83 | INSTALLED_APPS = ( 84 | 'django.contrib.auth', 85 | 'django.contrib.contenttypes', 86 | 'django.contrib.sessions', 87 | 'django.contrib.sites', 88 | 'django.contrib.messages', 89 | 'merlin', 90 | # Uncomment the next line to enable the admin: 91 | # 'django.contrib.admin', 92 | ) 93 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/templates/forms/base.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | Wizard Test 7 | 8 | 9 | {% block content %} 10 | {% endblock %} 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/templates/forms/social_wizard.html: -------------------------------------------------------------------------------- 1 | {% extends "forms/base.html" %} 2 | 3 | {% block content %} 4 |
{% csrf_token %} 5 | 6 | {{ form }} 7 |
8 | {% if previous_step %} 9 | Back 10 | {% endif %} 11 | {% if next_step %} 12 | 13 | {% else %} 14 | 15 | {% endif %} 16 | 17 |
18 |
{{ extra_context.global_id }}
19 | {% endblock %} 20 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/templates/forms/wizard.html: -------------------------------------------------------------------------------- 1 | {% extends "forms/base.html" %} 2 | 3 | {% block content %} 4 |
{% csrf_token %} 5 | 6 | {{ form }} 7 |
8 | {% if previous_step %} 9 | Back 10 | {% endif %} 11 | {% if next_step %} 12 | 13 | {% else %} 14 | 15 | {% endif %} 16 | 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | from merlin.tests.fixtures.testproject.wizard import MockWizard 4 | from merlin.wizards.utils import Step 5 | from merlin.wizards.session import SessionWizard 6 | 7 | from merlin.tests.fixtures.testproject import forms 8 | from merlin.tests.fixtures.testproject import views 9 | 10 | urlpatterns = patterns('', 11 | url(r'^simpletest$', SessionWizard([ 12 | Step('user-details', forms.UserDetailsForm), 13 | Step('contact-details', forms.ContactDetailsForm)])), 14 | url(r'^simpletest/(?P[A-Za-z0-9_-]+)$', SessionWizard([ 15 | Step('user-details', forms.UserDetailsForm), 16 | Step('contact-details', forms.ContactDetailsForm)])), 17 | url(r'^bettertest/(?P[A-Za-z0-9_-]+)$', MockWizard([ 18 | Step('user-details', forms.UserDetailsForm), 19 | Step('contact-details', forms.ContactDetailsForm)])), 20 | url(r'^$', views.index, name='test-index'), 21 | url(r'^more$', views.more, name='test-more'), 22 | ) 23 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/views.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | 4 | def index(request): 5 | return HttpResponse("Index page") 6 | 7 | 8 | def more(request): 9 | return HttpResponse("More page") 10 | -------------------------------------------------------------------------------- /src/merlin/tests/fixtures/testproject/wizard.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | 3 | from merlin.tests.fixtures.testproject import forms 4 | from merlin.wizards.session import SessionWizard 5 | from merlin.wizards.utils import Step 6 | 7 | 8 | class MockWizard(SessionWizard): 9 | def initialize(self, request, wizard_state): 10 | if not 'global_id' in wizard_state: 11 | wizard_state.global_id = '123456789' 12 | 13 | def done(self, request): 14 | form_data = self.get_form_data(request) 15 | assert form_data['user-details']['first_name'] == 'Chad' 16 | assert form_data['user-details']['last_name'] == 'Gallemore' 17 | assert form_data['user-details']['email'] == 'cgallemore@gmail.com' 18 | assert form_data['few-more-things']['bio'] == 'My bio' 19 | assert form_data['social-info']['twitter'] == 'http://twitter.com/localbase' 20 | assert form_data['social-info']['facebook'] == 'http://facebook.com/localbase' 21 | 22 | self.clear(request) 23 | 24 | return HttpResponse("All done", mimetype="text/plain") 25 | 26 | def process_step(self, request, current_step, form): 27 | if current_step.slug == 'user-details': 28 | few_more_things_step = Step('few-more-things', forms.FewMoreThingsForm) 29 | self.insert_after(request, current_step, few_more_things_step) 30 | 31 | social_step = Step('social-info', forms.SocialForm) 32 | contact_step = self.get_step(request, 'contact-details') 33 | self.insert_before(request, contact_step, social_step) 34 | 35 | self.remove_step(request, contact_step) 36 | 37 | def get_template(self, request, step, form): 38 | if step.slug == 'social-info': 39 | return 'forms/social_wizard.html' 40 | 41 | else: 42 | return 'forms/wizard.html' 43 | 44 | def process_show_form(self, request, step, form): 45 | if step.slug == 'social-info': 46 | return { 47 | 'global_id': self._get_state(request).global_id} 48 | -------------------------------------------------------------------------------- /src/merlin/tests/test_session_wizard.py: -------------------------------------------------------------------------------- 1 | from BeautifulSoup import BeautifulSoup 2 | from django.core.urlresolvers import reverse 3 | from django.test import TestCase 4 | 5 | from merlin.tests.fixtures.testproject import forms 6 | from merlin.wizards import MissingStepException, MissingSlugException 7 | from merlin.wizards.session import SessionWizard 8 | from merlin.wizards.utils import Step 9 | 10 | 11 | class SessionWizardTest(TestCase): 12 | 13 | def test_type_error_if_session_wizard_is_not_list(self): 14 | try: 15 | SessionWizard(( 16 | Step('user-details', forms.UserDetailsForm), 17 | Step('contact-details', forms.ContactDetailsForm)) 18 | ) 19 | self.fail("We shouldn't be allowed to create a SessionWizard with a tuple") 20 | except TypeError as te: 21 | self.assertEquals(te.message, 'steps must be an instance of or subclass of list') 22 | 23 | except Exception as e: 24 | self.fail("We should only fail with a TypeError, exception was %s" % e) 25 | 26 | def test_type_error_if_step_is_not_type_step(self): 27 | try: 28 | SessionWizard([ 29 | ('user-details', forms.UserDetailsForm), 30 | ('contact-details', forms.ContactDetailsForm)] 31 | ) 32 | self.fail("We shouldn't be allowed to create a SessionWizard with a tuple") 33 | except TypeError as te: 34 | self.assertEquals(te.message, 'All steps must be an instance of Step') 35 | 36 | except Exception as e: 37 | self.fail("We should only fail with a TypeError, exception was %s" % e) 38 | 39 | def test_session_wizard_no_slug(self): 40 | with self.assertRaises(MissingSlugException): 41 | self.client.get('/simpletest') 42 | 43 | def test_form_not_valid(self): 44 | response = self.client.get('/simpletest/user-details') 45 | self.assertEquals(response.status_code, 200) 46 | 47 | soup = BeautifulSoup(response.content) 48 | self.assertTrue(soup.find('input', id='id_first_name')) 49 | self.assertTrue(soup.find('input', id='id_last_name')) 50 | self.assertTrue(soup.find('input', id='id_email')) 51 | self.assertTrue(soup.find('a', href="/simpletest/contact-details")) 52 | self.assertFalse(soup.find('a', text="Back")) 53 | 54 | post = self.client.post('/simpletest/user-details', {}) 55 | self.assertEquals(post.status_code, 200) 56 | 57 | #The form should be invalid, and it should put us on the same form as before. 58 | soup = BeautifulSoup(post.content) 59 | self.assertTrue(soup.find('input', id='id_first_name')) 60 | self.assertTrue(soup.find('input', id='id_last_name')) 61 | self.assertTrue(soup.find('input', id='id_email')) 62 | self.assertTrue(soup.find('a', href="/simpletest/contact-details")) 63 | self.assertFalse(soup.find('a', text="Back")) 64 | 65 | def test_session_wizard(self): 66 | response = self.client.get('/simpletest/user-details') 67 | self.assertEquals(response.status_code, 200) 68 | 69 | soup = BeautifulSoup(response.content) 70 | self.assertTrue(soup.find('input', id='id_first_name')) 71 | self.assertTrue(soup.find('input', id='id_last_name')) 72 | self.assertTrue(soup.find('input', id='id_email')) 73 | self.assertTrue(soup.find('a', href="/simpletest/contact-details")) 74 | self.assertFalse(soup.find('a', text="Back")) 75 | 76 | post = self.client.post('/simpletest/user-details', { 77 | 'first_name': 'Chad', 78 | 'last_name': 'Gallemore', 79 | 'email': 'cgallemore@gmail.com' 80 | }, follow=True) 81 | 82 | self.assertEquals(post.redirect_chain[0], 83 | ('http://testserver/simpletest/contact-details', 302)) 84 | self.assertEquals(post.status_code, 200) 85 | 86 | soup = BeautifulSoup(post.content) 87 | self.assertTrue(soup.find('input', id="id_street_address")) 88 | self.assertTrue(soup.find('input', id="id_city")) 89 | self.assertTrue(soup.find('input', id="id_state")) 90 | self.assertTrue(soup.find('input', id="id_zipcode")) 91 | self.assertTrue(soup.find('input', id="id_phone")) 92 | self.assertFalse(soup.find('a', text="Next")) 93 | self.assertTrue(soup.find('a', text="Back")) 94 | self.assertTrue(soup.find('a', href="/simpletest/user-details")) 95 | 96 | try: 97 | post_last = self.client.post(post.request['PATH_INFO'], { 98 | 'street_address': '122 Main St.', 99 | 'city': 'Joplin', 100 | 'state': 'MO', 101 | 'zipcode': '64801', 102 | 'phone': '5555555555' 103 | }) 104 | 105 | self.fail("this should have raised a not implemented error") 106 | 107 | except NotImplementedError as nie: 108 | self.assertEquals(nie.message, "Your SessionWizard class has not " \ 109 | "defined a done() method, which is required.") 110 | 111 | except Exception as e: 112 | self.fail("We should have raised a not implemented error, " \ 113 | "instead the exception was %s" % e) 114 | 115 | def test_session_wizard_cancel_default(self): 116 | response = self.client.get('/simpletest/user-details') 117 | self.assertEquals(response.status_code, 200) 118 | 119 | post = self.client.post('/simpletest/user-details', { 120 | 'first_name': 'Chad', 121 | 'last_name': 'Gallemore', 122 | 'email': 'cgallemore@gmail.com' 123 | }, follow=True) 124 | 125 | self.assertEquals(post.redirect_chain[0], 126 | ('http://testserver/simpletest/contact-details', 302)) 127 | self.assertEquals(post.status_code, 200) 128 | 129 | response = self.client.get('/simpletest/cancel', follow=True) 130 | 131 | self.assertEquals(response.redirect_chain[0], 132 | ('http://testserver/', 302)) 133 | self.assertEquals(response.status_code, 200) 134 | 135 | def test_session_wizard_cancel_with_redirect(self): 136 | response = self.client.get('/simpletest/user-details') 137 | self.assertEquals(response.status_code, 200) 138 | 139 | post = self.client.post('/simpletest/user-details', { 140 | 'first_name': 'Chad', 141 | 'last_name': 'Gallemore', 142 | 'email': 'cgallemore@gmail.com' 143 | }, follow=True) 144 | 145 | self.assertEquals(post.redirect_chain[0], 146 | ('http://testserver/simpletest/contact-details', 302)) 147 | self.assertEquals(post.status_code, 200) 148 | 149 | response = self.client.get('/simpletest/cancel?rd=%s' % reverse( 150 | 'test-more'), follow=True) 151 | 152 | self.assertEquals(response.redirect_chain[0], 153 | ('http://testserver/more', 302)) 154 | self.assertEquals(response.status_code, 200) 155 | 156 | 157 | class MockWizardTest(TestCase): 158 | 159 | def test_mock_wizard(self): 160 | response = self.client.get('/bettertest/user-details') 161 | self.assertEquals(response.status_code, 200) 162 | 163 | soup = BeautifulSoup(response.content) 164 | self.assertTrue(soup.find('input', id='id_first_name')) 165 | self.assertTrue(soup.find('input', id='id_last_name')) 166 | self.assertTrue(soup.find('input', id='id_email')) 167 | 168 | post = self.client.post('/bettertest/user-details', { 169 | 'first_name': 'Chad', 170 | 'last_name': 'Gallemore', 171 | 'email': 'cgallemore@gmail.com' 172 | }, follow=True) 173 | 174 | self.assertEquals(post.redirect_chain[0], 175 | ('http://testserver/bettertest/few-more-things', 302)) 176 | self.assertEquals(post.status_code, 200) 177 | 178 | soup = BeautifulSoup(post.content) 179 | self.assertTrue(soup.find('input', id="id_bio")) 180 | 181 | post = self.client.post(post.request['PATH_INFO'], { 182 | 'bio': 'My bio' 183 | }, follow=True) 184 | 185 | self.assertEquals(post.redirect_chain[0], 186 | ('http://testserver/bettertest/social-info', 302)) 187 | self.assertEquals(post.status_code, 200) 188 | 189 | soup = BeautifulSoup(post.content) 190 | self.assertTrue(soup.find('input', id="id_twitter")) 191 | self.assertTrue(soup.find('input', id="id_facebook")) 192 | 193 | div = soup.find('div', id="global_id") 194 | 195 | self.assertEquals(div.string, '123456789') 196 | 197 | post = self.client.post(post.request['PATH_INFO'], { 198 | 'twitter': 'http://twitter.com/localbase', 199 | 'facebook': 'http://facebook.com/localbase' 200 | }, follow=True) 201 | 202 | self.assertEquals(post.status_code, 200) 203 | self.assertEquals(post.content, 'All done') 204 | -------------------------------------------------------------------------------- /src/merlin/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from merlin.tests.fixtures.testproject.forms import * 4 | from merlin.wizards.utils import * 5 | 6 | 7 | class UtilsTestCase(unittest.TestCase): 8 | def test_init_with_no_params(self): 9 | state = WizardState() 10 | 11 | self.assertIsNone(state.steps) 12 | self.assertIsNone(state.current_step) 13 | self.assertIsNone(state.form_data) 14 | 15 | def test_init_with_params(self): 16 | step1 = Step('step1', ContactDetailsForm) 17 | step2 = Step('step2', UserDetailsForm) 18 | 19 | state = WizardState(steps=[step1, step2], current_step=step1, 20 | form_data={}) 21 | 22 | self.assertListEqual(state.steps, [step1, step2]) 23 | self.assertEqual(state.current_step, step1) 24 | self.assertDictEqual(state.form_data, {}) 25 | 26 | def test_step_object_methods(self): 27 | step1 = Step('step1', ContactDetailsForm) 28 | step1_copy = Step('step1', UserDetailsForm) 29 | step2 = Step('step2', UserDetailsForm) 30 | 31 | self.assertRaises(ValueError, Step, 'step1', Step) 32 | 33 | self.assertTrue(step1 == step1_copy) 34 | self.assertFalse(step1 == step2) 35 | self.assertFalse(step1 == 'step1') 36 | self.assertTrue(step1 != step2) 37 | self.assertFalse(step1 != step1_copy) 38 | 39 | self.assertEquals(str(step1), 'step1') 40 | self.assertEquals(unicode(step1), u'step1') 41 | 42 | self.assertEquals('Step: %s' % repr(step1), 'Step: step1') 43 | 44 | def test_wizard_expansion(self): 45 | state = WizardState() 46 | 47 | if not hasattr(state, 'test_param'): 48 | state.test_param = 'Test' 49 | 50 | self.assertEquals(state.test_param, 'Test') 51 | 52 | state.test_param = 'Test 2' 53 | 54 | self.assertEquals(state.test_param, 'Test 2') 55 | 56 | state = WizardState() 57 | state.another_param = 'Another Test' 58 | 59 | if not hasattr(state, 'another_param'): 60 | self.fail('We should have the param') 61 | 62 | else: 63 | self.assertEquals(state.another_param, 'Another Test') 64 | -------------------------------------------------------------------------------- /src/merlin/wizards/__init__.py: -------------------------------------------------------------------------------- 1 | class MissingSlugException(Exception): 2 | pass 3 | 4 | 5 | class MissingStepException(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /src/merlin/wizards/session.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.http import * 4 | from django.shortcuts import render_to_response 5 | from django.template.context import RequestContext 6 | from merlin.wizards import MissingStepException, MissingSlugException 7 | 8 | from merlin.wizards.utils import * 9 | 10 | 11 | def modifies_session(func): 12 | @wraps(func) 13 | def wrapper(self, request, *args, **kwargs): 14 | result = func(self, request, *args, **kwargs) 15 | request.session.modified = True 16 | 17 | return result 18 | return wrapper 19 | 20 | 21 | class SessionWizard(object): 22 | """ 23 | This class allows for the ability to chop up a long form into sizable steps 24 | and process each step in sequence. It also provides the ability to go back 25 | to a previous step or move on to the next step in the sequence. When the 26 | wizard runs out of steps it calls a final function that finishes the form 27 | process. This class should be subclassed and the subclass should at a 28 | minimum override the ``done`` method. 29 | 30 | .. versionadded:: 0.1 31 | 32 | :param steps: 33 | Provides a list of :class:`Step` objects in the order in 34 | which the wizard should display them to the user. This list can 35 | be manipulated to add or remove steps as needed. 36 | """ 37 | def __init__(self, steps): 38 | if not isinstance(steps, list): 39 | raise TypeError('steps must be an instance of or subclass of list') 40 | 41 | if [step for step in steps if not isinstance(step, Step)]: 42 | raise TypeError('All steps must be an instance of Step') 43 | 44 | slugs = set([step.slug for step in steps]) 45 | 46 | # By putting the slugs into a set the duplicates will be filtered out. 47 | # If the slug list length does not equal the steps length then there 48 | # must have been duplicates. 49 | if len(slugs) != len(steps): 50 | raise ValueError('Step slugs must be unique.') 51 | 52 | clazz = self.__class__ 53 | 54 | self.id = '%s.%s' % (clazz.__module__, clazz.__name__,) 55 | self.base_steps = steps 56 | 57 | def __call__(self, request, *args, **kwargs): 58 | """ 59 | Initialize the step list for the session if needed and call the proper 60 | HTTP method handler. 61 | """ 62 | self._init_wizard(request) 63 | 64 | slug = kwargs.get('slug', None) 65 | 66 | if not slug: 67 | raise MissingSlugException("Slug not found.") 68 | 69 | step = self.get_step(request, slug) 70 | 71 | if not step: 72 | if slug == 'cancel': 73 | self.cancel(request) 74 | redirect = request.REQUEST.get('rd', '/') 75 | 76 | return HttpResponseRedirect(redirect) 77 | 78 | raise MissingStepException("Step for slug %s not found." % slug) 79 | 80 | method_name = 'process_%s' % request.method 81 | method = getattr(self, method_name) 82 | 83 | return method(request, step) 84 | 85 | 86 | def _init_wizard(self, request): 87 | """ 88 | Since the SessionWizard can be used as the callable for the urlconf 89 | there will be only one instance of the class created. We need to 90 | make sure each session has its own copy of the step list to manipulate. 91 | This way multiple connections will not trample on each others steps. 92 | """ 93 | if self.id not in request.session: 94 | request.session[self.id] = WizardState( 95 | steps=self.base_steps[:], # Copies the list 96 | current_step=self.base_steps[0], 97 | form_data={}) 98 | 99 | self.initialize(request, request.session[self.id]) 100 | 101 | def _get_state(self, request): 102 | """ 103 | Returns the :class:`WizardState` object used to manage this 104 | wizards internal state. 105 | """ 106 | return request.session[self.id] 107 | 108 | def _show_form(self, request, step, form): 109 | """ 110 | Render the provided form for the provided step to the 111 | response stream. 112 | """ 113 | context = self.process_show_form(request, step, form) 114 | 115 | return self.render_form(request, step, form, { 116 | 'current_step': step, 117 | 'form': form, 118 | 'previous_step': self.get_before(request, step), 119 | 'next_step': self.get_after(request, step), 120 | 'url_base': self._get_URL_base(request, step), 121 | 'extra_context': context 122 | }) 123 | 124 | def _set_current_step(self, request, step): 125 | """ 126 | Sets the currenlty executing step. 127 | """ 128 | self._get_state(request).current_step = step 129 | 130 | return step 131 | 132 | def _get_URL_base(self, request, step): 133 | """ 134 | Returns the base URL of the wizard. 135 | """ 136 | index = request.path.rfind(step.slug) 137 | 138 | return request.path[:index] 139 | 140 | def process_GET(self, request, step): 141 | """ 142 | Renders the ``Form`` for the requested :class:`Step` 143 | """ 144 | form_data = self.get_cleaned_data(request, step) 145 | 146 | if form_data: 147 | form = step.form(form_data) 148 | 149 | else: 150 | form = step.form() 151 | 152 | return self._show_form(request, step, form) 153 | 154 | def process_POST(self, request, step): 155 | """ 156 | Processes the current :class:`Step` and either send a redirect to the 157 | next :class:`Step` in the sequence or finished the wizard process 158 | by calling ``self.done`` 159 | """ 160 | form = step.form(request.POST) 161 | 162 | if not form.is_valid(): 163 | return self._show_form(request, step, form) 164 | 165 | self.set_cleaned_data(request, step, form.cleaned_data) 166 | self.process_step(request, step, form) 167 | next_step = self.get_after(request, step) 168 | 169 | if next_step: 170 | url_base = self._get_URL_base(request, step) 171 | 172 | return HttpResponseRedirect(urljoin(url_base, next_step.slug)) 173 | 174 | else: 175 | return self.done(request) 176 | 177 | def get_steps(self, request): 178 | """ 179 | Returns the list of :class:`Step`s used in this wizard sequence. 180 | 181 | :param request: 182 | A ``HttpRequest`` object that carries along with it the session 183 | used to access the wizard state. 184 | """ 185 | return self._get_state(request).steps 186 | 187 | def get_step(self, request, slug): 188 | """ 189 | Returns the :class:`Step` that matches the provided slug. 190 | 191 | :param request: 192 | A ``HttpRequest`` object that carries along with it the session 193 | used to access the wizard state. 194 | 195 | :param slug: 196 | The unique identifier for a particular :class:`Step` in the 197 | sequence. 198 | """ 199 | steps = self.get_steps(request) 200 | 201 | try: 202 | return [step for step in steps if step.slug == slug][0] 203 | 204 | except IndexError: 205 | return None 206 | 207 | def get_before(self, request, step): 208 | """ 209 | Returns the previous :class:`Step` in the sequence after the provided 210 | :class:`Step`. This function will return ``None`` if there is no 211 | previous step. 212 | 213 | :param request: 214 | A ``HttpRequest`` object that carries along with it the session 215 | used to access the wizard state. 216 | 217 | :param step: 218 | The :class:`Step` to use as an index for finding the next 219 | :class:`Step` 220 | """ 221 | steps = self.get_steps(request) 222 | index = steps.index(step) 223 | 224 | if index > 0: 225 | return steps[index - 1] 226 | 227 | else: 228 | return None 229 | 230 | def get_after(self, request, step): 231 | """ 232 | Returns the next :class:`Step` in the sequence after the provided 233 | :class:`Step`. This function will return ``None`` if there is no 234 | next step. 235 | 236 | :param request: 237 | A ``HttpRequest`` object that carries along with it the session 238 | used to access the wizard state. 239 | 240 | :param step: 241 | The :class:`Step` to use as an index for finding the next 242 | :class:`Step` 243 | """ 244 | steps = self.get_steps(request) 245 | index = steps.index(step) 246 | 247 | try: 248 | return steps[index + 1] 249 | 250 | except IndexError: 251 | return None 252 | 253 | @modifies_session 254 | def remove_step(self, request, step): 255 | """ 256 | Removes step from the wizard sequence. 257 | 258 | :param request: 259 | A ``HttpRequest`` object that carries along with it the session 260 | used to access the wizard state. 261 | 262 | :param step: 263 | The :class:`Step` to remove. 264 | """ 265 | steps = self.get_steps(request) 266 | 267 | if step in steps: 268 | steps.remove(step) 269 | 270 | @modifies_session 271 | def insert_before(self, request, current_step, step): 272 | """ 273 | Inserts a new step into the wizard sequence before the provided step. 274 | 275 | :param request: 276 | A ``HttpRequest`` object that carries along with it the session 277 | used to access the wizard state. 278 | 279 | :param current_step: 280 | The :class:`Step` to use as an index for inserting a new step 281 | 282 | :param step: 283 | The new :class:`Step` to insert. 284 | """ 285 | steps = self.get_steps(request) 286 | 287 | if step not in steps: 288 | index = steps.index(current_step) 289 | steps.insert(index, step) 290 | 291 | @modifies_session 292 | def insert_after(self, request, current_step, step): 293 | """ 294 | Inserts a new step into the wizard sequence after the provided step. 295 | 296 | :param request: 297 | A ``HttpRequest`` object that carries along with it the session 298 | used to access the wizard state. 299 | 300 | :param current_step: 301 | The :class:`Step` to use as an index for inserting a new step 302 | 303 | :param step: 304 | The new :class:`Step` to insert. 305 | """ 306 | steps = self.get_steps(request) 307 | 308 | if step not in steps: 309 | index = steps.index(current_step) + 1 310 | steps.insert(index, step) 311 | 312 | def get_cleaned_data(self, request, step): 313 | """ 314 | Returns the cleaned form data for the provided step. 315 | 316 | :param request: 317 | A ``HttpRequest`` object that carries along with it the session 318 | used to access the wizard state. 319 | 320 | :param step: 321 | The :class:`Step` to use to pull the cleaned form data. 322 | """ 323 | return self._get_state(request).form_data.get(step.slug, None) 324 | 325 | @modifies_session 326 | def set_cleaned_data(self, request, step, data): 327 | """ 328 | Sets the cleaned form data for the provided step. 329 | 330 | :param request: 331 | A ``HttpRequest`` object that carries along with it the session 332 | used to access the wizard state. 333 | 334 | :param step: 335 | The :class:`Step` to use to store the cleaned form data. 336 | 337 | :param data: 338 | The cleaned ``Form`` data to store. 339 | """ 340 | self._get_state(request).form_data[step.slug] = data 341 | 342 | def get_form_data(self, request): 343 | """ 344 | This will return the form_data dictionary that has been saved in the 345 | session. This will mainly be used in the done to query for the form_data 346 | that has been saved throughout the wizard process. 347 | 348 | :param request: 349 | A ``HttpRequest`` object that carries along with it the session 350 | used to access the wizard state. 351 | """ 352 | return request.session[self.id]['form_data'] 353 | 354 | def clear(self, request): 355 | """ 356 | Removes the internal wizard state from the session. This should be 357 | called right be for the return from a successful 358 | :meth:`~SessionWizard.done()` call. 359 | """ 360 | del request.session[self.id] 361 | 362 | # METHODS SUBCLASSES MIGHT OVERRIDE IF APPROPRIATE ######################## 363 | def initialize(self, request, wizard_state): 364 | """ 365 | Hook used to initialize the wizard subclass. This will be called for 366 | every request to the wizard before it processes the GET or POST. 367 | 368 | :param request: 369 | A ``HttpRequest`` object for this request. 370 | 371 | :param wizard_state: 372 | The :class:`WizardState` object representing the current state of 373 | the wizard. Extra information can be appended to the state so it 374 | can be available to :class:`Step`'s of the wizard. 375 | 376 | For example:: 377 | if 'profile' not in wizard_state: 378 | wizard_state.profile = request.user.get_profile() 379 | """ 380 | pass 381 | 382 | def cancel(self, request): 383 | """ 384 | Hook used to cancel a wizard. This will be called when slug is passed 385 | that matches "cancel". By default the method will clear the session 386 | data. 387 | 388 | :param request: 389 | A ``HttpRequest`` object for this request. 390 | """ 391 | self.clear(request) 392 | 393 | def process_show_form(self, request, step, form): 394 | """ 395 | Hook used for providing extra context that can be used in the 396 | template used to render the current form. 397 | 398 | :param request: 399 | A ``HttpRequest`` object that carries along with it the session 400 | used to access the wizard state. 401 | 402 | :param step: 403 | The current :class:`Step` that is being processed. 404 | 405 | :param form: 406 | The Django ``Form`` object that is being processed. 407 | """ 408 | pass 409 | 410 | def process_step(self, request, step, form): 411 | """ 412 | Hook for modifying the ``SessionWizard``'s internal state, given a fully 413 | validated ``Form`` object. The ``Form`` is guaranteed to have clean, 414 | valid data. 415 | 416 | This method should *not* modify any of that data. Rather, it might want 417 | dynamically alter the step list, based on previously submitted forms. 418 | 419 | :param request: 420 | A ``HttpRequest`` object that carries along with it the session 421 | used to access the wizard state. 422 | 423 | :param step: 424 | The current :class:`Step` that is being processed. 425 | 426 | :param form: 427 | The Django ``Form`` object that is being processed. 428 | """ 429 | pass 430 | 431 | def get_template(self, request, step, form): 432 | """ 433 | Responsible for return the path to the template that should be used 434 | to render this current form. 435 | 436 | :param request: 437 | A ``HttpRequest`` object that carries along with it the session 438 | used to access the wizard state. 439 | 440 | :param step: 441 | The current :class:`Step` that is being processed. 442 | 443 | :param form: 444 | The Django ``Form`` object that is being processed. 445 | """ 446 | return 'forms/wizard.html' 447 | 448 | def render_form(self, request, step, form, context): 449 | """ 450 | Renders a form with the provided context and returns a ``HttpResponse`` 451 | object. This can be overridden to provide custom rendering to the 452 | client or using a different template engine. 453 | 454 | :param request: 455 | A ``HttpRequest`` object that carries along with it the session 456 | used to access the wizard state. 457 | 458 | :param step: 459 | The current :class:`Step` that is being processed. 460 | 461 | :param form: 462 | The Django ``Form`` object that is being processed. 463 | 464 | :param context: 465 | The default context that templates can use which also contains 466 | any extra context created in the ``process_show_form`` hook. 467 | """ 468 | return render_to_response(self.get_template(request, step, form), 469 | context, RequestContext(request)) 470 | 471 | def done(self, request): 472 | """ 473 | Responsible for processing the validated form data that the wizard 474 | collects from the user. This function should be overridden by the 475 | implementing subclass. This function needs to return a ``HttpResponse`` 476 | object. 477 | 478 | :param request: 479 | A ``HttpRequest`` object that carries along with it the session 480 | used to access the wizard state. 481 | """ 482 | raise NotImplementedError("Your %s class has not defined a done() " \ 483 | "method, which is required." \ 484 | % self.__class__.__name__) 485 | -------------------------------------------------------------------------------- /src/merlin/wizards/utils.py: -------------------------------------------------------------------------------- 1 | from UserDict import UserDict 2 | 3 | from django import forms 4 | 5 | 6 | __all__ = ('Step', 'WizardState',) 7 | 8 | 9 | class Step(object): 10 | """ 11 | When constucting a form wizard, the wizard needs to be composed of a 12 | sequental series of steps in which it is to display forms to the user and 13 | collect the data from those forms. To be able to provide these forms to the 14 | :ref:`SessionWizard `, you must first wrap the Django 15 | :class:`django.forms.Form` in a ``Step`` object. The ``Step`` object gives 16 | the ability to store the :class:`django.forms.Form` class to be used, as 17 | well as, a unique slug to be used in the wizard navigation. 18 | 19 | .. versionadded:: 0.1 20 | 21 | :param slug: 22 | Each step in the wizard should have a unique "slug" that identifies that 23 | ``Step`` in the process. By using slugs the wizard has the ability to go 24 | forward, as well as, back in the process adjusting what data it collects 25 | from the user. 26 | 27 | :param form: 28 | This *MUST* be a subclass of :class:`django.forms.Form` or 29 | :class:`django.forms.ModelForm`. This should not be an instance of that 30 | subclass. The :ref:`SessionWizard ` will use this 31 | class to create instances for the user. If going back in the wizard 32 | process, the :ref:`SessionWizard ` will prepopulate 33 | the form with any cleaned data already collected. 34 | """ 35 | def __init__(self, slug, form): 36 | if not issubclass(form, (forms.Form, forms.ModelForm,)): 37 | raise ValueError('Form must be subclass of a Django Form') 38 | 39 | self.slug = str(slug) 40 | self.form = form 41 | 42 | def __hash__(self): 43 | return hash(self.slug) 44 | 45 | def __eq__(self, other): 46 | if isinstance(other, Step): 47 | return self.__hash__() == other.__hash__() 48 | 49 | return False 50 | 51 | def __ne__(self, other): 52 | return not self == other 53 | 54 | def __str__(self): 55 | return str(self.slug) 56 | 57 | def __unicode__(self): 58 | return unicode(self.slug) 59 | 60 | def __repr__(self): 61 | return str(self) 62 | 63 | 64 | class WizardState(UserDict): 65 | """ 66 | This class provides the ability for a 67 | :ref:`SessionWizard ` to keep track of the important 68 | state of a multi-step form. Instead of keeping track of the state through 69 | :samp:`` fields, it subclasses the python ``UserDict`` 70 | object and stores its data in the properties ``steps``,``current_step`` 71 | and ``form_data``. 72 | 73 | .. versionadded:: 0.1 74 | 75 | :param steps: 76 | A list of the :ref:`Step ` objects that provide the sequence 77 | in which the forms should be presented to the user. 78 | 79 | :param current_step: 80 | The current :ref:`Step ` that the user is currently on. 81 | 82 | :param form_data: 83 | A ``dict`` of the cleaned form data collected to this point and 84 | referenced using the :ref:`Step `'s slug as the key to 85 | the ``dict`` 86 | """ 87 | def __init__(self, *args, **kwargs): 88 | UserDict.__init__(self, *args, **kwargs) 89 | 90 | self.steps = kwargs.get('steps', None) 91 | self.current_step = kwargs.get('current_step', None) 92 | self.form_data = kwargs.get('form_data', None) 93 | --------------------------------------------------------------------------------