Version 0.1.0 0.1.0
authorThierry Florac <thierry.florac@onf.fr>
Wed, 17 Jun 2015 09:58:33 +0200
changeset 0 6f99128c6d48
child 1 b9ee7c6e9deb
Version 0.1.0
.hgignore
.installed.cfg
LICENSE
MANIFEST.in
bootstrap.py
buildout.cfg
docs/HISTORY.txt
docs/README.txt
setup.py
src/pyams_portal.egg-info/PKG-INFO
src/pyams_portal.egg-info/SOURCES.txt
src/pyams_portal.egg-info/dependency_links.txt
src/pyams_portal.egg-info/entry_points.txt
src/pyams_portal.egg-info/namespace_packages.txt
src/pyams_portal.egg-info/not-zip-safe
src/pyams_portal.egg-info/requires.txt
src/pyams_portal.egg-info/top_level.txt
src/pyams_portal/__init__.py
src/pyams_portal/doctests/README.txt
src/pyams_portal/include.py
src/pyams_portal/interfaces/__init__.py
src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.mo
src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.po
src/pyams_portal/locales/pyams_portal.pot
src/pyams_portal/page.py
src/pyams_portal/portlet.py
src/pyams_portal/portlets/__init__.py
src/pyams_portal/portlets/context/__init__.py
src/pyams_portal/portlets/context/context.pt
src/pyams_portal/portlets/context/interfaces.py
src/pyams_portal/portlets/image/__init__.py
src/pyams_portal/portlets/image/image.pt
src/pyams_portal/portlets/image/interfaces.py
src/pyams_portal/resources/css/portal.css
src/pyams_portal/resources/js/portal.js
src/pyams_portal/resources/js/portal.min.js
src/pyams_portal/resources/less/portal.less
src/pyams_portal/site.py
src/pyams_portal/slot.py
src/pyams_portal/template.py
src/pyams_portal/tests/__init__.py
src/pyams_portal/tests/test_utilsdocs.py
src/pyams_portal/tests/test_utilsdocstrings.py
src/pyams_portal/workflow.py
src/pyams_portal/zmi/__init__.py
src/pyams_portal/zmi/container.py
src/pyams_portal/zmi/interfaces.py
src/pyams_portal/zmi/portlet.py
src/pyams_portal/zmi/portlets/__init__.py
src/pyams_portal/zmi/portlets/context.py
src/pyams_portal/zmi/portlets/image.py
src/pyams_portal/zmi/portlets/templates/context-preview.pt
src/pyams_portal/zmi/portlets/templates/image-preview.pt
src/pyams_portal/zmi/template/__init__.py
src/pyams_portal/zmi/template/config.py
src/pyams_portal/zmi/template/page.py
src/pyams_portal/zmi/template/templates/config.pt
src/pyams_portal/zmi/template/workflow.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.hgignore	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,19 @@
+
+syntax: regexp
+^develop-eggs$
+syntax: regexp
+^parts$
+syntax: regexp
+^bin$
+syntax: regexp
+^\.installed\.cfg$
+syntax: regexp
+^\.settings$
+syntax: regexp
+^build$
+syntax: regexp
+^dist$
+syntax: regexp
+^\.idea$
+syntax: regexp
+.*\.pyc$
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.installed.cfg	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,103 @@
+[buildout]
+installed_develop_eggs = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs/pyams-portal.egg-link
+parts = package i18n pyflakes test
+
+[package]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pcreate
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pdistreport
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/ptweens
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pshell
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pserve
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/prequest
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pviews
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/proutes
+__buildout_signature__ = zc.recipe.egg-2.0.1-py3.4.egg setuptools-15.1-py3.4.egg zc.buildout-2.3.1-py3.4.egg
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+eggs = pyams_portal
+	pyramid
+	zope.component
+	zope.interface
+eggs-directory = /var/local/env/pyams/eggs
+recipe = zc.recipe.egg
+
+[i18n]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pybabel
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pot-create
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/polint
+__buildout_signature__ = zc.recipe.egg-2.0.1-py3.4.egg setuptools-15.1-py3.4.egg zc.buildout-2.3.1-py3.4.egg
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+eggs = babel
+	lingua
+eggs-directory = /var/local/env/pyams/eggs
+recipe = zc.recipe.egg
+
+[pyflakes]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pyflakes
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/pyflakes
+__buildout_signature__ = zc.recipe.egg-2.0.1-py3.4.egg setuptools-15.1-py3.4.egg zc.buildout-2.3.1-py3.4.egg
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+eggs = pyflakes
+eggs-directory = /var/local/env/pyams/eggs
+entry-points = pyflakes=pyflakes.scripts.pyflakes:main
+initialization = if not sys.argv[1:]: sys.argv[1:] = ["src"]
+recipe = zc.recipe.egg
+scripts = pyflakes
+
+[test]
+__buildout_installed__ = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/parts/test
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/test
+__buildout_signature__ = zc.recipe.testrunner-2.0.0-py3.4.egg zc.recipe.egg-2.0.1-py3.4.egg setuptools-15.1-py3.4.egg zope.testrunner-4.4.6-py3.4.egg zc.buildout-2.3.1-py3.4.egg zope.interface-4.1.2-py3.4-linux-x86_64.egg zope.exceptions-4.0.7-py3.4.egg six-e6b62e54b4df360c40dfcbb76c1ecf1a
+_b = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+_d = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+_e = /var/local/env/pyams/eggs
+bin-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin
+develop-eggs-directory = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs
+eggs = pyams_portal [test]
+eggs-directory = /var/local/env/pyams/eggs
+location = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/parts/test
+recipe = zc.recipe.testrunner
+script = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/bin/test
+
+[buildout]
+installed_develop_eggs = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs/lingua.egg-link
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs/pyams-portal.egg-link
+
+[buildout]
+parts = i18n pyflakes test package
+
+[buildout]
+parts = pyflakes test package i18n
+
+[buildout]
+parts = test package i18n pyflakes
+
+[buildout]
+parts = package i18n pyflakes test
+
+[buildout]
+installed_develop_eggs = /home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs/lingua.egg-link
+	/home/tflorac/Dropbox/src/PyAMS/pyams_portal/develop-eggs/pyams-portal.egg-link
+
+[buildout]
+parts = i18n pyflakes test package
+
+[buildout]
+parts = pyflakes test package i18n
+
+[buildout]
+parts = test package i18n pyflakes
+
+[buildout]
+parts = package i18n pyflakes test
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/LICENSE	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,42 @@
+Zope Public License (ZPL) Version 2.1
+=====================================
+
+A copyright notice accompanies this license document that identifies
+the copyright holders.
+
+This license has been certified as open source. It has also been designated
+as GPL compatible by the Free Software Foundation (FSF).
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+   1. Redistributions in source code must retain the accompanying copyright
+      notice, this list of conditions, and the following disclaimer.
+   2. Redistributions in binary form must reproduce the accompanying copyright
+      notice, this list of conditions, and the following disclaimer in the
+      documentation and/or other materials provided with the distribution.
+   3. Names of the copyright holders must not be used to endorse or promote
+      products derived from this software without prior written permission
+      from the copyright holders.
+   4. The right to distribute this software or to use it for any purpose does
+      not give you the right to use Servicemarks (sm) or Trademarks (tm) of the
+      copyright holders. Use of them is covered by separate agreement with the
+      copyright holders.
+   5. If any files are modified, you must cause the modified files to carry
+      prominent notices stating that you changed the files and the date of any
+      change.
+
+
+Disclaimer
+==========
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY EXPRESSED
+OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
+EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MANIFEST.in	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,5 @@
+include *.txt
+recursive-include docs *
+recursive-include src *
+global-exclude *.pyc
+global-exclude *.*~
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bootstrap.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,178 @@
+##############################################################################
+#
+# Copyright (c) 2006 Zope Foundation and Contributors.
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+##############################################################################
+"""Bootstrap a buildout-based project
+
+Simply run this script in a directory containing a buildout.cfg.
+The script accepts buildout command-line options, so you can
+use the -c option to specify an alternate configuration file.
+"""
+
+import os
+import shutil
+import sys
+import tempfile
+
+from optparse import OptionParser
+
+tmpeggs = tempfile.mkdtemp()
+
+usage = '''\
+[DESIRED PYTHON FOR BUILDOUT] bootstrap.py [options]
+
+Bootstraps a buildout-based project.
+
+Simply run this script in a directory containing a buildout.cfg, using the
+Python that you want bin/buildout to use.
+
+Note that by using --find-links to point to local resources, you can keep 
+this script from going over the network.
+'''
+
+parser = OptionParser(usage=usage)
+parser.add_option("-v", "--version", help="use a specific zc.buildout version")
+
+parser.add_option("-t", "--accept-buildout-test-releases",
+                  dest='accept_buildout_test_releases',
+                  action="store_true", default=False,
+                  help=("Normally, if you do not specify a --version, the "
+                        "bootstrap script and buildout gets the newest "
+                        "*final* versions of zc.buildout and its recipes and "
+                        "extensions for you.  If you use this flag, "
+                        "bootstrap and buildout will get the newest releases "
+                        "even if they are alphas or betas."))
+parser.add_option("-c", "--config-file",
+                  help=("Specify the path to the buildout configuration "
+                        "file to be used."))
+parser.add_option("-f", "--find-links",
+                  help=("Specify a URL to search for buildout releases"))
+parser.add_option("--allow-site-packages",
+                  action="store_true", default=False,
+                  help=("Let bootstrap.py use existing site packages"))
+
+
+options, args = parser.parse_args()
+
+######################################################################
+# load/install setuptools
+
+try:
+    if options.allow_site_packages:
+        import setuptools
+        import pkg_resources
+    from urllib.request import urlopen
+except ImportError:
+    from urllib2 import urlopen
+
+ez = {}
+exec(urlopen('https://bootstrap.pypa.io/ez_setup.py').read(), ez)
+
+if not options.allow_site_packages:
+    # ez_setup imports site, which adds site packages
+    # this will remove them from the path to ensure that incompatible versions 
+    # of setuptools are not in the path
+    import site
+    # inside a virtualenv, there is no 'getsitepackages'. 
+    # We can't remove these reliably
+    if hasattr(site, 'getsitepackages'):
+        for sitepackage_path in site.getsitepackages():
+            sys.path[:] = [x for x in sys.path if sitepackage_path not in x]
+
+setup_args = dict(to_dir=tmpeggs, download_delay=0)
+ez['use_setuptools'](**setup_args)
+import setuptools
+import pkg_resources
+
+# This does not (always?) update the default working set.  We will
+# do it.
+for path in sys.path:
+    if path not in pkg_resources.working_set.entries:
+        pkg_resources.working_set.add_entry(path)
+
+######################################################################
+# Install buildout
+
+ws = pkg_resources.working_set
+
+cmd = [sys.executable, '-c',
+       'from setuptools.command.easy_install import main; main()',
+       '-mZqNxd', tmpeggs]
+
+find_links = os.environ.get(
+    'bootstrap-testing-find-links',
+    options.find_links or
+    ('http://downloads.buildout.org/'
+     if options.accept_buildout_test_releases else None)
+    )
+if find_links:
+    cmd.extend(['-f', find_links])
+
+setuptools_path = ws.find(
+    pkg_resources.Requirement.parse('setuptools')).location
+
+requirement = 'zc.buildout'
+version = options.version
+if version is None and not options.accept_buildout_test_releases:
+    # Figure out the most recent final version of zc.buildout.
+    import setuptools.package_index
+    _final_parts = '*final-', '*final'
+
+    def _final_version(parsed_version):
+        for part in parsed_version:
+            if (part[:1] == '*') and (part not in _final_parts):
+                return False
+        return True
+    index = setuptools.package_index.PackageIndex(
+        search_path=[setuptools_path])
+    if find_links:
+        index.add_find_links((find_links,))
+    req = pkg_resources.Requirement.parse(requirement)
+    if index.obtain(req) is not None:
+        best = []
+        bestv = None
+        for dist in index[req.project_name]:
+            distv = dist.parsed_version
+            if _final_version(distv):
+                if bestv is None or distv > bestv:
+                    best = [dist]
+                    bestv = distv
+                elif distv == bestv:
+                    best.append(dist)
+        if best:
+            best.sort()
+            version = best[-1].version
+if version:
+    requirement = '=='.join((requirement, version))
+cmd.append(requirement)
+
+import subprocess
+if subprocess.call(cmd, env=dict(os.environ, PYTHONPATH=setuptools_path)) != 0:
+    raise Exception(
+        "Failed to execute command:\n%s" % repr(cmd)[1:-1])
+
+######################################################################
+# Import and run buildout
+
+ws.add_entry(tmpeggs)
+ws.require(requirement)
+import zc.buildout.buildout
+
+if not [a for a in args if '=' not in a]:
+    args.append('bootstrap')
+
+# if -c was provided, we push it back into args for buildout' main function
+if options.config_file is not None:
+    args[0:0] = ['-c', options.config_file]
+
+zc.buildout.buildout.main(args)
+shutil.rmtree(tmpeggs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/buildout.cfg	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,61 @@
+[buildout]
+eggs-directory = /var/local/env/pyams/eggs
+
+socket-timeout = 3
+show-picked-versions = true
+newest = false
+
+allow-hosts =
+    bitbucket.org
+    *.python.org
+    *.sourceforge.net
+    github.com
+
+#extends = http://download.ztfy.org/webapp/ztfy.webapp.dev.cfg
+versions = versions
+newest = false
+#allow-picked-versions = false
+
+src = src
+develop =
+    .
+    /var/local/src/pyams/ext/lingua
+
+parts =
+    package
+    i18n
+    pyflakes
+    test
+
+[package]
+recipe = zc.recipe.egg
+eggs =
+    pyams_portal
+    pyramid
+    zope.component
+    zope.interface
+
+[i18n]
+recipe = zc.recipe.egg
+eggs =
+    babel
+    lingua
+
+[pyflakes]
+recipe = zc.recipe.egg
+eggs = pyflakes
+scripts = pyflakes
+entry-points = pyflakes=pyflakes.scripts.pyflakes:main
+initialization = if not sys.argv[1:]: sys.argv[1:] = ["${buildout:src}"]
+
+[pyflakesrun]
+recipe = collective.recipe.cmd
+on_install = true
+cmds = ${buildout:develop}/bin/${pyflakes:scripts}
+
+[test]
+recipe = zc.recipe.testrunner
+eggs = pyams_portal [test]
+
+[versions]
+pyams_portal = 0.1.0
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/docs/README.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,68 @@
+
+
+Portal template configuration storage
+=====================================
+
+
+<PortalTemplate mon_template>
+	name = 'template1'
+	__annotations__['pyams_portal.template'] = PortalTemplateConfiguration:
+		__parent__ = mon_template
+		rows = 2
+		slot_names = ['slot1', 'slot2', 'slot3']
+		slot_order = {0: ['slot1', 'slot2],
+					  1: ['slot3']}
+		slots = {0: {'slot1': [1, 2],
+					 'slot2': [3, 4, 5]},
+				 1: {'slot3': []}},
+		slot_config = {'slot1': <SlotConfiguration>,
+					   'slot2': <SlotConfiguration>,
+					   'slot3': <SlotConfiguration>},
+		portlets = {1: 'portlet1',
+					2: 'portlet2',
+					3: 'portlet1',
+					4: 'portlet3',
+					5: 'portlet1'},
+	__annotations__['pyams_portal.portlets'] = PortalPortletsConfiguration:
+		portlet_config = {1: <Portlet1Configuration(portlet='portlet1')>,
+						  2: <Portlet2Configuration(portlet='portlet2')>},
+						  3: <Portlet1Configuration(portlet='portlet1')>,
+						  4: <Portlet3Configuration(portlet='portlet3')>,
+						  5: <Portlet1Configuration(portlet='portlet1')>}}
+
+
+<PortalContext mon_contexte>
+	shared_template = 'template1'
+	template = mon_template
+	__annotations__['pyams_portal.template'] = PortalTemplateConfiguration:
+		__parent__ = mon_contexte
+		slots = {0: {'slot1': [1, 2],
+					 'slot2': [3, 4, 5]},
+				 1: {'slot3': []}},
+		slot_config = {'slot1': <SlotConfiguration>,
+					   'slot2': <SlotConfiguration>,
+					   'slot3': <SlotConfiguration>},
+	__annotations__['pyams_portal.portlets'] = PortalPortletsConfiguration:
+		portlet_config = {1: <Portlet1Configuration(portlet='portlet1')>,
+						  2: <Portlet2Configuration(portlet='portlet2')>},
+						  3: <Portlet1Configuration(portlet='portlet1')>,
+						  4: <Portlet3Configuration(portlet='portlet3')>,
+						  5: <Portlet1Configuration(portlet='portlet1')>}}
+
+
+<IPortalContext context1>:
+	<PortalPage>
+		__parent__ = context1
+		inherit_parent = False
+		use_local_template = False
+		shared_template = 'template1'
+
+
+<IPortalContext context2>:
+	<PortalPage>
+		__parent__ = context2
+		inherit_parent = False
+		use_local_template = True
+		local_template = <PortalWfTemplate ++template++>
+		__annotations__['pyams_portal.portlets'] = PortalPortletsConfiguration:
+			__parent__ = ++template++
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/setup.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,69 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+This module contains pyams_portal package
+"""
+import os
+from setuptools import setup, find_packages
+
+DOCS = os.path.join(os.path.dirname(__file__),
+                    'docs')
+
+README = os.path.join(DOCS, 'README.txt')
+HISTORY = os.path.join(DOCS, 'HISTORY.txt')
+
+version = '0.1.0'
+long_description = open(README).read() + '\n\n' + open(HISTORY).read()
+
+tests_require = []
+
+setup(name='pyams_portal',
+      version=version,
+      description="PyAMS portal and portlets interfaces and classes",
+      long_description=long_description,
+      classifiers=[
+          "License :: OSI Approved :: Zope Public License",
+          "Development Status :: 4 - Beta",
+          "Programming Language :: Python",
+          "Framework :: Pyramid",
+          "Topic :: Software Development :: Libraries :: Python Modules",
+      ],
+      keywords='Pyramid PyAMS portal portlets',
+      author='Thierry Florac',
+      author_email='tflorac@ulthar.net',
+      url='http://hg.ztfy.org/pyams/pyams_portal',
+      license='ZPL',
+      packages=find_packages('src'),
+      package_dir={'': 'src'},
+      namespace_packages=[],
+      include_package_data=True,
+      package_data={'': ['*.zcml', '*.txt', '*.pt', '*.pot', '*.po', '*.mo',
+                         '*.png', '*.gif', '*.jpeg', '*.jpg', '*.css', '*.js']},
+      zip_safe=False,
+      # uncomment this to be able to run tests with setup.py
+      test_suite="pyams_portal.tests.test_utilsdocs.test_suite",
+      tests_require=tests_require,
+      extras_require=dict(test=tests_require),
+      install_requires=[
+          'setuptools',
+          # -*- Extra requirements: -*-
+          'fanstatic',
+          'pyramid',
+          'zope.component',
+          'zope.interface',
+      ],
+      entry_points={
+          'fanstatic.libraries': [
+              'pyams_portal = pyams_portal:library'
+          ]
+      })
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/PKG-INFO	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,86 @@
+Metadata-Version: 1.1
+Name: pyams-portal
+Version: 0.1.0
+Summary: PyAMS portal and portlets interfaces and classes
+Home-page: http://hg.ztfy.org/pyams/pyams_portal
+Author: Thierry Florac
+Author-email: tflorac@ulthar.net
+License: ZPL
+Description: 
+        
+        Portal template configuration storage
+        =====================================
+        
+        
+        <PortalTemplate mon_template>
+        	name = 'template1'
+        	__annotations__['pyams_portal.template'] = PortalTemplateConfiguration:
+        		__parent__ = mon_template
+        		rows = 2
+        		slot_names = ['slot1', 'slot2', 'slot3']
+        		slot_order = {0: ['slot1', 'slot2],
+        					  1: ['slot3']}
+        		slots = {0: {'slot1': [1, 2],
+        					 'slot2': [3, 4, 5]},
+        				 1: {'slot3': []}},
+        		slot_config = {'slot1': <SlotConfiguration>,
+        					   'slot2': <SlotConfiguration>,
+        					   'slot3': <SlotConfiguration>},
+        		portlets = {1: 'portlet1',
+        					2: 'portlet2',
+        					3: 'portlet1',
+        					4: 'portlet3',
+        					5: 'portlet1'},
+        	__annotations__['pyams_portal.portlets'] = PortalPortletsConfiguration:
+        		portlet_config = {1: <Portlet1Configuration(portlet='portlet1')>,
+        						  2: <Portlet2Configuration(portlet='portlet2')>},
+        						  3: <Portlet1Configuration(portlet='portlet1')>,
+        						  4: <Portlet3Configuration(portlet='portlet3')>,
+        						  5: <Portlet1Configuration(portlet='portlet1')>}}
+        
+        
+        <PortalContext mon_contexte>
+        	shared_template = 'template1'
+        	template = mon_template
+        	__annotations__['pyams_portal.template'] = PortalTemplateConfiguration:
+        		__parent__ = mon_contexte
+        		slots = {0: {'slot1': [1, 2],
+        					 'slot2': [3, 4, 5]},
+        				 1: {'slot3': []}},
+        		slot_config = {'slot1': <SlotConfiguration>,
+        					   'slot2': <SlotConfiguration>,
+        					   'slot3': <SlotConfiguration>},
+        	__annotations__['pyams_portal.portlets'] = PortalPortletsConfiguration:
+        		portlet_config = {1: <Portlet1Configuration(portlet='portlet1')>,
+        						  2: <Portlet2Configuration(portlet='portlet2')>},
+        						  3: <Portlet1Configuration(portlet='portlet1')>,
+        						  4: <Portlet3Configuration(portlet='portlet3')>,
+        						  5: <Portlet1Configuration(portlet='portlet1')>}}
+        
+        
+        <IPortalContext context1>:
+        	<PortalPage>
+        		__parent__ = context1
+        		inherit_parent = False
+        		use_local_template = False
+        		shared_template = 'template1'
+        
+        
+        <IPortalContext context2>:
+        	<PortalPage>
+        		__parent__ = context2
+        		inherit_parent = False
+        		use_local_template = True
+        		local_template = <PortalWfTemplate ++template++>
+        		__annotations__['pyams_portal.portlets'] = PortalPortletsConfiguration:
+        			__parent__ = ++template++
+        
+        
+        
+Keywords: Pyramid PyAMS portal portlets
+Platform: UNKNOWN
+Classifier: License :: OSI Approved :: Zope Public License
+Classifier: Development Status :: 4 - Beta
+Classifier: Programming Language :: Python
+Classifier: Framework :: Pyramid
+Classifier: Topic :: Software Development :: Libraries :: Python Modules
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/SOURCES.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,54 @@
+MANIFEST.in
+setup.py
+docs/HISTORY.txt
+docs/README.txt
+src/pyams_portal/__init__.py
+src/pyams_portal/include.py
+src/pyams_portal/page.py
+src/pyams_portal/portlet.py
+src/pyams_portal/site.py
+src/pyams_portal/slot.py
+src/pyams_portal/template.py
+src/pyams_portal/workflow.py
+src/pyams_portal.egg-info/PKG-INFO
+src/pyams_portal.egg-info/SOURCES.txt
+src/pyams_portal.egg-info/dependency_links.txt
+src/pyams_portal.egg-info/entry_points.txt
+src/pyams_portal.egg-info/namespace_packages.txt
+src/pyams_portal.egg-info/not-zip-safe
+src/pyams_portal.egg-info/requires.txt
+src/pyams_portal.egg-info/top_level.txt
+src/pyams_portal/doctests/README.txt
+src/pyams_portal/interfaces/__init__.py
+src/pyams_portal/locales/pyams_portal.pot
+src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.mo
+src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.po
+src/pyams_portal/portlets/__init__.py
+src/pyams_portal/portlets/context/__init__.py
+src/pyams_portal/portlets/context/context.pt
+src/pyams_portal/portlets/context/interfaces.py
+src/pyams_portal/portlets/image/__init__.py
+src/pyams_portal/portlets/image/image.pt
+src/pyams_portal/portlets/image/interfaces.py
+src/pyams_portal/resources/css/portal.css
+src/pyams_portal/resources/css/portal.min.css
+src/pyams_portal/resources/js/portal.js
+src/pyams_portal/resources/js/portal.min.js
+src/pyams_portal/resources/less/portal.less
+src/pyams_portal/tests/__init__.py
+src/pyams_portal/tests/test_utilsdocs.py
+src/pyams_portal/tests/test_utilsdocstrings.py
+src/pyams_portal/zmi/__init__.py
+src/pyams_portal/zmi/container.py
+src/pyams_portal/zmi/interfaces.py
+src/pyams_portal/zmi/portlet.py
+src/pyams_portal/zmi/portlets/__init__.py
+src/pyams_portal/zmi/portlets/context.py
+src/pyams_portal/zmi/portlets/image.py
+src/pyams_portal/zmi/portlets/templates/context-preview.pt
+src/pyams_portal/zmi/portlets/templates/image-preview.pt
+src/pyams_portal/zmi/template/__init__.py
+src/pyams_portal/zmi/template/config.py
+src/pyams_portal/zmi/template/page.py
+src/pyams_portal/zmi/template/workflow.py
+src/pyams_portal/zmi/template/templates/config.pt
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/dependency_links.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/entry_points.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,3 @@
+[fanstatic.libraries]
+pyams_portal = pyams_portal:library
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/namespace_packages.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/not-zip-safe	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/requires.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,7 @@
+setuptools
+fanstatic
+pyramid
+zope.component
+zope.interface
+
+[test]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal.egg-info/top_level.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+pyams_portal
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,36 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+from fanstatic import Library
+library = Library('pyams_portal', 'resources')
+
+from pyramid.i18n import TranslationStringFactory
+_ = TranslationStringFactory('pyams_portal')
+
+
+def includeme(config):
+    """Pyramid include"""
+
+    from .include import include_package
+    include_package(config)
+
+    # register custom permissions
+    config.register_permission({'id': 'portal.templates.manage',
+                                'title': _("Manage portal templates")})
+
+    # register custom roles
+    config.register_role({'id': 'portal.TemplatesManager',
+                          'title': _("Portal templates manager"),
+                          'permissions': {'portal.templates.manage', 'view', 'system.view'}})
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/doctests/README.txt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,3 @@
+====================
+pyams_portal package
+====================
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/include.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,35 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
+
+
+def include_package(config):
+    """Pyramid include"""
+
+    # add translations
+    config.add_translation_dirs('pyams_portal:locales')
+
+    # load registry components
+    try:
+        import pyams_zmi
+    except ImportError:
+        config.scan(ignore='pyams_portal.zmi')
+    else:
+        config.scan()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/interfaces/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,357 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_workflow.interfaces import IWorkflowManagedContent
+from zope.annotation.interfaces import IAttributeAnnotatable
+from zope.container.interfaces import IContainer
+from zope.contentprovider.interfaces import IContentProvider
+
+# import packages
+from pyams_security.schema import PermissionField
+from pyams_utils.schema import PersistentDict, PersistentList
+from zope.container.constraints import contains
+from zope.interface import invariant, Interface, Attribute, Invalid
+from zope.schema import List, TextLine, Object, Int, Bool, Choice
+
+from pyams_portal import _
+
+
+class IPortlet(Interface):
+    """Portlet interface"""
+
+    name = Attribute("Portlet internal name")
+
+    label = Attribute("Portlet visible name")
+
+    permission = PermissionField(title="Portlet permission",
+                                 description="Permission required to display permission",
+                                 required=False)
+
+    toolbar_image = Attribute("Porlet toolbar image")
+
+    toolbar_css_class = Attribute("Portlet toolbar CSS class")
+
+
+class IPortletAddingInfo(Interface):
+    """Portlet adding info interface"""
+
+    portlet_name = Choice(title=_("Portlet"),
+                          vocabulary="PyAMS portal portlets")
+
+    slot_name = Choice(title=_("Slot name"),
+                       description=_("Slot name to which this configuration applies"),
+                       required=True,
+                       vocabulary='PyAMS template slots')
+
+
+class IPortletConfiguration(Interface):
+    """Portlet configuration interface"""
+
+    template = Attribute("Template to which this configuration applis")
+
+    slot_name = TextLine(title=_("Slot name"),
+                         description=_("Slot name to which this configuration applies"),
+                         required=True)
+
+    portlet_name = Attribute("Portlet name")
+
+    position = Int(title=_("Position"),
+                   description=_("Portlet position inside slot"),
+                   required=True,
+                   min=0)
+
+    visible = Bool(title=_("Visible portlet?"),
+                   description=_("Select 'no' to hide this portlet. This will not break configuration inheritance..."),
+                   required=True,
+                   default=True)
+
+    can_inherit = Attribute("Can inherit parent configuration?")
+
+    inherit_parent = Bool(title=_("Inherit parent configuration?"),
+                          description=_("This option is only available if context's parent is using the same template "
+                                        "and if this portlet is also present in the same slot..."),
+                          required=True,
+                          default=True)
+
+
+class IPortletContentProvider(IContentProvider):
+    """Portlet content provider"""
+
+    portlet = Object(title="Portlet utility",
+                     schema=IPortlet)
+
+    configuration = Object(title="Portlet renderer configuration",
+                           schema=IPortletConfiguration)
+
+
+class IPortletPreviewer(IPortletContentProvider):
+    """Portlet previewer interface
+
+    A portlet previewer should be defined as an adapter for a context,
+    a request, a view and a portlet
+    """
+
+
+class IPortletRenderer(IPortletContentProvider):
+    """Portlet renderer interface
+
+    A portlet renderer should be defined as an adapter for a context,
+    a request, a view and a portlet
+    """
+
+
+class ISlot(Interface):
+    """Portal template slot interface"""
+
+    name = TextLine(title=_("Slot name"),
+                    description=_("This name must be unique in a given template"),
+                    required=True)
+
+    row_id = Int(title=_("Row ID"),
+                 required=False)
+
+
+class ISlotConfiguration(Interface):
+    """Portal slot configuration"""
+
+    template = Attribute("Slot template")
+
+    slot_name = TextLine(title="Slot name")
+
+    visible = Bool(title=_("Visible slot?"),
+                   description=_("Select 'no' to hide this slot. This will not break configuration inheritance..."),
+                   required=True,
+                   default=True)
+
+    can_inherit = Attribute("Can inherit parent configuration?")
+
+    inherit_parent = Bool(title=_("Inherit parent configuration?"),
+                          description=_("This option is only available if context's parent template is using a "
+                                        "template containing the same slot..."),
+                          required=True,
+                          default=True)
+
+    xs_width = Int(title=_("Extra small device width"),
+                   description=_("Slot width, in columns count, on extra small devices (phones...); "
+                                 "set to 0 to hide the portlet"),
+                   required=False,
+                   min=0,
+                   max=12)
+
+    sm_width = Int(title=_("Small device width"),
+                   description=_("Slot width, in columns count, on small devices (tablets...); "
+                                 "set to 0 to hide the portlet"),
+                   required=False,
+                   min=0,
+                   max=12)
+
+    md_width = Int(title=_("Medium devices width"),
+                   description=_("Slot width, in columns count, on medium desktop devices (>= 992 pixels); "
+                                 "set to 0 to hide the portlet"),
+                   required=False,
+                   min=0,
+                   max=12)
+
+    lg_width = Int(title=_("Large devices width"),
+                   description=_("Slot width, in columns count, on large desktop devices (>= 1200 pixels); "
+                                 "set to 0 to hide the portlet"),
+                   required=False,
+                   min=0,
+                   max=12)
+
+    css_class = TextLine(title=_("CSS class"),
+                         description=_("CSS class applied to this slot"),
+                         required=False)
+
+    def get_css_class(self, device=None):
+        """Get current CSS class"""
+
+    def get_width(self, device=None):
+        """Get slot width for each or given device"""
+
+    def set_width(self, width, device=None):
+        """Set width in columns count for given device"""
+
+
+class ISlotRenderer(IContentProvider):
+    """Slot renderer"""
+
+
+class IPortalTemplateConfiguration(Interface):
+    """Portal template configuration interface"""
+
+    rows = Int(title="Rows count",
+               required=True,
+               default=1,
+               min=0)
+
+    def add_row(self):
+        """Add new row"""
+
+    def set_row_order(self, order):
+        """Change template rows order"""
+
+    def delete_row(self, row_id):
+        """Delete template row"""
+
+    slot_names = PersistentList(title="Slot names",
+                                value_type=TextLine())
+
+    slot_order = PersistentDict(title="Solts order",
+                                key_type=Int(),  # row index
+                                value_type=PersistentList(value_type=TextLine()))  # slot name
+
+    slots = PersistentDict(title="Slots portlets",
+                           description="List of slots associated with a given template",
+                           key_type=Int(),  # row index
+                           value_type=PersistentDict(key_type=TextLine(),  # slot name
+                                                     value_type=PersistentList(value_type=Choice(
+                                                         vocabulary='PyAMS portal portlets')),  # portlet names
+                                                     required=False))
+
+    slot_config = PersistentDict(title="Slots configuration",
+                                 key_type=TextLine(),  # slot name
+                                 value_type=Object(schema=ISlotConfiguration),
+                                 required=False)
+
+    def add_slot(self, slot_name, row_id=None):
+        """Add slot with given name"""
+
+    def set_slot_order(self, order):
+        """Change template slots order"""
+
+    def get_slot_row(self, slot_name):
+        """Get row containing given slot"""
+
+    def get_slots(self, row_id):
+        """Get ordered list of slots for given row ID"""
+
+    def get_slots_width(self, device=None):
+        """Get slots width for given or all device(s)"""
+
+    def set_slot_width(self, slot_name, device, width):
+        """Set slot width for given device"""
+
+    def get_slot_configuration(self, slot_name):
+        """Get slot configuration for given slot"""
+
+    def delete_slot(self, slot_name):
+        """Delete template slot"""
+
+
+# class IPortalPortletsConfiguration(Interface):
+#     """Portal template portlets configuration interface"""
+#
+    portlet_config = PersistentDict(title="Portlet configuration",
+                                    key_type=TextLine(),  # slot name
+                                    value_type=PersistentDict(key_type=Int(min=0),  # portlet position inside slot
+                                                              value_type=Object(schema=IPortletConfiguration)),
+                                    required=False)
+
+    def add_portlet(self, portlet_name, slot_name):
+        """Add portlet to givben slot"""
+
+    def set_portlet_order(self, order):
+        """Set template portlets order"""
+
+    def get_portlet_configuration(self, slot_name, position):
+        """Get portlet configuration for given slot"""
+
+    def delete_portlet(self, slot_name, position):
+        """Delete template portlet"""
+
+
+class IPortalTemplate(IAttributeAnnotatable):
+    """Portal template interface
+
+    A portal template is a named utility providing a name and a set of slots.
+    """
+
+    name = TextLine(title=_("Template name"),
+                    description=_("Two registered templates can't share the same name..."),
+                    required=True)
+
+
+class IPortalWfTemplate(IWorkflowManagedContent):
+    """Workflow managed portal template interface"""
+
+
+class IPortalTemplateContainer(IContainer, IAttributeAnnotatable):
+    """Portal template container interface"""
+
+    contains(IPortalTemplate)
+
+
+class IPortalTemplateContainerConfiguration(Interface):
+    """Portal templates container configuration"""
+
+    selected_portlets = List(title=_("Selected portlets"),
+                             description=_("These portlets will be directly available in templates configuration "
+                                           "page toolbar"),
+                             value_type=Choice(vocabulary="PyAMS portal portlets"),
+                             required=False)
+
+
+class IPortalTemplateRenderer(IContentProvider):
+    """Portal template renderer
+
+    A portal template renderer should be implemented as an adapter for a context, a request
+    and a template
+    """
+
+
+class IPortalPage(Interface):
+    """Portal page interface
+
+    The page is the highest configuration level.
+    It defines which template is used (a shared or local one), which gives
+    the slots list.
+    """
+
+    can_inherit = Attribute("Can inherit parent template?")
+
+    inherit_parent = Bool(title=_("Inherit parent template?"),
+                          description=_("Should we reuse parent template?"),
+                          required=True,
+                          default=True)
+
+    use_local_template = Bool(title=_("Use local template?"),
+                              description=_("If 'yes', you can define a custom local template instead of "
+                                            "a shared template"),
+                              required=True,
+                              default=False)
+
+    shared_template = Choice(title=_("Page template"),
+                             description=_("Template used for this page"),
+                             vocabulary='PyAMS portal templates',
+                             required=False)
+
+    @invariant
+    def check_template(self):
+        if not (self.use_local_template or self.shared_template):
+            raise Invalid(_("You must choose to use a local template or select a shared one!"))
+
+    local_template = Object(title=_("Local template"),
+                            schema=IPortalWfTemplate,
+                            required=False)
+
+    template = Attribute("Used template")
+
+
+class IPortalContext(IAttributeAnnotatable):
+    """Portal context marker interface"""
Binary file src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.mo has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.po	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,504 @@
+#
+# French translations for PACKAGE package
+# This file is distributed under the same license as the PACKAGE package.
+# Thierry Florac <tflorac@ulthar.net>, 2015.
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE 1.0\n"
+"POT-Creation-Date: 2015-05-12 13:59+0200\n"
+"PO-Revision-Date: 2015-05-12 12:10+0200\n"
+"Last-Translator: Thierry Florac <tflorac@ulthar.net>\n"
+"Language-Team: French <traduc@traduc.org>\n"
+"Language: fr\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Lingua 3.10.dev0\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
+
+#: src/pyams_portal/workflow.py:41
+msgid "Draft"
+msgstr "Brouillon"
+
+#: src/pyams_portal/workflow.py:42
+msgid "Published"
+msgstr "Publié"
+
+#: src/pyams_portal/workflow.py:43
+msgid "Retired"
+msgstr "Retiré"
+
+#: src/pyams_portal/workflow.py:44
+msgid "Archived"
+msgstr "Archivé"
+
+#: src/pyams_portal/workflow.py:45
+msgid "Deleted"
+msgstr "Supprimé"
+
+#: src/pyams_portal/workflow.py:103
+msgid "Initialize"
+msgstr "Initialiser"
+
+#: src/pyams_portal/workflow.py:108
+msgid "Publish..."
+msgstr "Publier..."
+
+#: src/pyams_portal/workflow.py:116
+msgid ""
+"This content is currently in DRAFT mode.\n"
+"                                               Publishing it will make it "
+"publicly visible."
+msgstr ""
+"Ce modèle est actuellement en mode BROUILLON.\n"
+"En le publiant, vous le rendrez visible."
+
+#: src/pyams_portal/workflow.py:120
+msgid "Retire..."
+msgstr "Retirer..."
+
+#: src/pyams_portal/workflow.py:128
+msgid ""
+"This content is actually published.\n"
+"                                                 You can retire it to make "
+"it invisible, but contents using this\n"
+"                                                 template won't be visible "
+"anymore!"
+msgstr ""
+"Ce modèle est actuellement publié.\n"
+"Vous pouvez le retirer pour le rendre invisible, mais les contenus qui "
+"utilisent ce modèle ne seront plus consultables !"
+
+#: src/pyams_portal/workflow.py:133 src/pyams_portal/workflow.py:181
+msgid "Create new version..."
+msgstr "Créer une nouvelle version..."
+
+#: src/pyams_portal/workflow.py:143
+msgid "Re-publish..."
+msgstr "Re-publier..."
+
+#: src/pyams_portal/workflow.py:151
+msgid ""
+"This content was published and retired.\n"
+"                                                 You can re-publish it to "
+"make it visible again."
+msgstr ""
+"Ce modèle a été publié puis retiré.\n"
+"Vous pouvez le re-publier pour le rendre à nouveau disponible."
+
+#: src/pyams_portal/workflow.py:155 src/pyams_portal/workflow.py:168
+msgid "Archive..."
+msgstr "Archiver..."
+
+#: src/pyams_portal/workflow.py:163
+msgid ""
+"This content is currently published.\n"
+"                                                  If it is archived, it will "
+"not be possible to make it visible again\n"
+"                                                  except by creating a new "
+"version!"
+msgstr ""
+"Ce modèle est actuellement publié.\n"
+"S'il est archivé, il ne sera plus possible de le rendre à nouveau "
+"disponible, sauf en créant une nouvelle version."
+
+#: src/pyams_portal/workflow.py:176
+msgid ""
+"This content has been published but is currently retired.\n"
+"                                                If it is archived, it will "
+"not be possible to make it visible again\n"
+"                                                except by creating a new "
+"version!"
+msgstr ""
+"Ce contenu a été publié mais est actuellement retiré.\n"
+"S'il est archivé, il ne sera plus possible de le rendre à nouveau "
+"disponible, sauf en créant une nouvelle version."
+
+#: src/pyams_portal/workflow.py:191
+msgid "Delete..."
+msgstr "Supprimer..."
+
+#: src/pyams_portal/workflow.py:199
+msgid ""
+"This content has never been published.\n"
+"                                    It can be removed and definitely deleted."
+msgstr ""
+"Ce modèle n'a jamais été publié.\n"
+"Vous pouvez donc le supprimer définitivement."
+
+#: src/pyams_portal/__init__.py:31
+msgid "Manage portal templates"
+msgstr "Gérer les modèles de présentation"
+
+#: src/pyams_portal/__init__.py:35
+msgid "Portal templates manager"
+msgstr "Gestionnaire des modèles"
+
+#: src/pyams_portal/zmi/portlet.py:41
+msgid "Edit portlet configuration"
+msgstr "Modifier la configuration d'un modèle"
+
+#: src/pyams_portal/zmi/portlet.py:38
+#, python-format
+msgid "« {0} »  portal template - {1}"
+msgstr "Modèle de présentation « {0} »  - {1}"
+
+#: src/pyams_portal/zmi/template/config.py:61
+msgid "Properties"
+msgstr "Propriétés"
+
+#: src/pyams_portal/zmi/template/config.py:72
+msgid "Portal template configuration"
+msgstr "Configuration d'un modèle"
+
+#: src/pyams_portal/zmi/template/config.py:120
+msgid "Portlets configuration"
+msgstr "Configuration des portlets"
+
+#: src/pyams_portal/zmi/template/config.py:133
+msgid "Add row..."
+msgstr "Ajouter une ligne..."
+
+#: src/pyams_portal/zmi/template/config.py:175
+msgid "Add slot..."
+msgstr "Ajouter un slot..."
+
+#: src/pyams_portal/zmi/template/config.py:191
+msgid "Add slot"
+msgstr "Ajout d'un slot"
+
+#: src/pyams_portal/zmi/template/config.py:265
+msgid "Edit slot properties"
+msgstr "Propriétés d'un slot"
+
+#: src/pyams_portal/zmi/template/config.py:333
+msgid "Add portlet..."
+msgstr "Ajouter un composant..."
+
+#: src/pyams_portal/zmi/template/config.py:349
+msgid "Add portlet"
+msgstr "Ajouter un composant"
+
+#: src/pyams_portal/zmi/template/config.py:209
+#: src/pyams_portal/zmi/template/__init__.py:269
+msgid "Specified name is already used!"
+msgstr "Le nom indiqué est déjà utilisé !"
+
+#: src/pyams_portal/zmi/template/config.py:118
+#: src/pyams_portal/zmi/template/config.py:189
+#: src/pyams_portal/zmi/template/config.py:347
+#, python-format
+msgid "« {0} »  portal template"
+msgstr "Modèle de présentation « {0} »"
+
+#: src/pyams_portal/zmi/template/config.py:262
+#, python-format
+msgid "« {0} »  portal template - {1} slot"
+msgstr "Modèle de présentation « {0} » - Slot {1}"
+
+#: src/pyams_portal/zmi/template/workflow.py:109
+msgid "Publish template"
+msgstr "Publier un modèle"
+
+#: src/pyams_portal/zmi/template/workflow.py:151
+msgid "Retire template"
+msgstr "Retirer un modèle"
+
+#: src/pyams_portal/zmi/template/workflow.py:180
+msgid "Archive template"
+msgstr "Archiver un modèle"
+
+#: src/pyams_portal/zmi/template/workflow.py:209
+#: src/pyams_portal/zmi/template/workflow.py:201
+msgid "Create new version"
+msgstr "Créer une nouvelle version"
+
+#: src/pyams_portal/zmi/template/workflow.py:100
+#: src/pyams_portal/zmi/template/workflow.py:142
+#: src/pyams_portal/zmi/template/workflow.py:171
+#: src/pyams_portal/zmi/template/workflow.py:200
+msgid "Close"
+msgstr "Fermer"
+
+#: src/pyams_portal/zmi/template/workflow.py:101
+msgid "Publish"
+msgstr "Publier"
+
+#: src/pyams_portal/zmi/template/workflow.py:143
+msgid "Retire"
+msgstr "Retirer"
+
+#: src/pyams_portal/zmi/template/workflow.py:172
+msgid "Archive"
+msgstr "Archiver"
+
+#: src/pyams_portal/zmi/template/__init__.py:88
+#: src/pyams_portal/zmi/template/__init__.py:196
+#: src/pyams_portal/zmi/template/__init__.py:240
+msgid "Portal templates"
+msgstr "Modèles de présentation"
+
+#: src/pyams_portal/zmi/template/__init__.py:97
+msgid "Shared portal templates"
+msgstr "Modèles de présentation partagés"
+
+#: src/pyams_portal/zmi/template/__init__.py:163
+msgid "Delete template"
+msgstr "Supprimer le modèle"
+
+#: src/pyams_portal/zmi/template/__init__.py:195
+msgid "Portal"
+msgstr "Portail"
+
+#: src/pyams_portal/zmi/template/__init__.py:229
+msgid "Add shared template..."
+msgstr "Ajouter un modèle partagé..."
+
+#: src/pyams_portal/zmi/template/__init__.py:241
+msgid "Add shared template"
+msgstr "Ajout d'un modèle de présentation"
+
+#: src/pyams_portal/zmi/template/__init__.py:153
+msgid "Older versions"
+msgstr "Versions précédentes"
+
+#: src/pyams_portal/zmi/template/__init__.py:212
+#: src/pyams_portal/zmi/template/__init__.py:146
+#, python-format
+msgid "Version {version} ({state} - last update {date})"
+msgstr "Version {version} ({state} - dernière modification {date})"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:15
+#: src/pyams_portal/zmi/template/templates/config.pt:29
+msgid "Version ${version} - ${state}"
+msgstr "Version ${version} - ${state}"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:42
+msgid "Selected display:"
+msgstr "Type de périphérique sélectionné :"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:47
+msgid "Current device"
+msgstr "Périphérique actuel"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:48
+msgid "Extra small device (phone)"
+msgstr "Très petits périphériques (téléphone)"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:49
+msgid "Small device (tablet)"
+msgstr "Petits périphériques (tablette)"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:50
+msgid "Medium desktop device (> 970px)"
+msgstr "Écrans de taille moyenne (> 970 px)"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:51
+msgid "Large desktop device (> 1170px)"
+msgstr "Écrans de grande taille (> 1170 px)"
+
+#: src/pyams_portal/zmi/template/templates/config.pt:111
+msgid "Delete row..."
+msgstr "Supprimer la ligne..."
+
+#: src/pyams_portal/zmi/template/templates/config.pt:119
+msgid "Edit slot properties..."
+msgstr "Propriétés..."
+
+#: src/pyams_portal/zmi/template/templates/config.pt:126
+msgid "Delete slot..."
+msgstr "Supprimer le slot..."
+
+#: src/pyams_portal/zmi/template/templates/config.pt:134
+msgid "Edit portlet properties..."
+msgstr "Propriétés..."
+
+#: src/pyams_portal/zmi/template/templates/config.pt:141
+msgid "Delete portlet..."
+msgstr "Supprimer le composant..."
+
+#: src/pyams_portal/portlets/context/__init__.py:43
+msgid "Context content"
+msgstr "Contenu du contexte"
+
+#: src/pyams_portal/portlets/image/__init__.py:44
+msgid "Image"
+msgstr "Image"
+
+#: src/pyams_portal/portlets/image/interfaces.py:30
+msgid "Selected image"
+msgstr "Image sélectionnée"
+
+#: src/pyams_portal/interfaces/__init__.py:49
+msgid "Portlet"
+msgstr "Composant"
+
+#: src/pyams_portal/interfaces/__init__.py:52
+#: src/pyams_portal/interfaces/__init__.py:63
+#: src/pyams_portal/interfaces/__init__.py:117
+msgid "Slot name"
+msgstr "Nom du slot"
+
+#: src/pyams_portal/interfaces/__init__.py:53
+#: src/pyams_portal/interfaces/__init__.py:64
+msgid "Slot name to which this configuration applies"
+msgstr "Nom du slot correspondant à la configuration"
+
+#: src/pyams_portal/interfaces/__init__.py:69
+msgid "Position"
+msgstr "Position"
+
+#: src/pyams_portal/interfaces/__init__.py:70
+msgid "Portlet position inside slot"
+msgstr "Position du composant au sein du slot"
+
+#: src/pyams_portal/interfaces/__init__.py:74
+msgid "Visible portlet?"
+msgstr "Composant visible ?"
+
+#: src/pyams_portal/interfaces/__init__.py:75
+msgid ""
+"Select 'no' to hide this portlet. This will not break configuration "
+"inheritance..."
+msgstr ""
+"Sélectionnez 'non' pour masquer ce composant. Ce paramètre pourra être "
+"surchargé par héritage au sein des pages qui utilisent ce composant."
+
+#: src/pyams_portal/interfaces/__init__.py:81
+#: src/pyams_portal/interfaces/__init__.py:139
+msgid "Inherit parent configuration?"
+msgstr "Hériter de la configuration du parent ?"
+
+#: src/pyams_portal/interfaces/__init__.py:82
+msgid ""
+"This option is only available if context's parent is using the same template "
+"and if this portlet is also present in the same slot..."
+msgstr ""
+"Cette option n'est disponible que si le parent utilise le même modèle de "
+"présentation et si ce composant est bien présent dans le même slot..."
+
+#: src/pyams_portal/interfaces/__init__.py:118
+msgid "This name must be unique in a given template"
+msgstr "Ce nom doit être unique au sein d'un modèle de présentation"
+
+#: src/pyams_portal/interfaces/__init__.py:121
+msgid "Row ID"
+msgstr "ID de la ligne"
+
+#: src/pyams_portal/interfaces/__init__.py:132
+msgid "Visible slot?"
+msgstr "Slot visible ?"
+
+#: src/pyams_portal/interfaces/__init__.py:133
+msgid ""
+"Select 'no' to hide this slot. This will not break configuration "
+"inheritance..."
+msgstr ""
+"Sélectionnez 'non' pour marquer ce slot. Ce paramètre pourra être surchargé "
+"par héritage..."
+
+#: src/pyams_portal/interfaces/__init__.py:140
+msgid ""
+"This option is only available if context's parent template is using a "
+"template containing the same slot..."
+msgstr ""
+"Cette option n'est disponible que si le parent utilise un modèle contenant "
+"un slot de même nom..."
+
+#: src/pyams_portal/interfaces/__init__.py:145
+msgid "Extra small device width"
+msgstr "Largeur sur très petits périphériques"
+
+#: src/pyams_portal/interfaces/__init__.py:146
+msgid ""
+"Slot width, in columns count, on extra small devices (phones...); set to 0 "
+"to hide the portlet"
+msgstr ""
+"Largeur du slot, en nombre de colonnes, sur les très petits périphériques "
+"(téléphones...) ; indiquez une valeur de 0 pour masquer ce composant"
+
+#: src/pyams_portal/interfaces/__init__.py:152
+msgid "Small device width"
+msgstr "Largeur sur petits périphériques"
+
+#: src/pyams_portal/interfaces/__init__.py:153
+msgid ""
+"Slot width, in columns count, on small devices (tablets...); set to 0 to "
+"hide the portlet"
+msgstr ""
+"Largeur du slot, en nombre de colonnes, sur les petits périphériques "
+"(tablettes...) ; indiquez une valeur de 0 pour masquer ce composant"
+
+#: src/pyams_portal/interfaces/__init__.py:159
+msgid "Medium devices width"
+msgstr "Largeur sur périphériques moyens"
+
+#: src/pyams_portal/interfaces/__init__.py:160
+msgid ""
+"Slot width, in columns count, on medium desktop devices (>= 992 pixels); set "
+"to 0 to hide the portlet"
+msgstr ""
+"Largeur du slot, en nombre de colonnes, sur les périphériques moyens (>= 992 "
+"pixels) ; indiquez une valeur de 0 pour masquer ce composant"
+
+#: src/pyams_portal/interfaces/__init__.py:166
+msgid "Large devices width"
+msgstr "Largeur sur grands périphériques"
+
+#: src/pyams_portal/interfaces/__init__.py:167
+msgid ""
+"Slot width, in columns count, on large desktop devices (>= 1200 pixels); set "
+"to 0 to hide the portlet"
+msgstr ""
+"Largeur du slot, en nombre de colonnes, sur les grands périphériques (>= "
+"1200 pixels) ; indiquez une valeur de 0 pour masquer ce composant"
+
+#: src/pyams_portal/interfaces/__init__.py:173
+msgid "CSS class"
+msgstr "Class CSS"
+
+#: src/pyams_portal/interfaces/__init__.py:174
+msgid "CSS class applied to this slot"
+msgstr "Classe CSS spécifique appliquée à ce slot"
+
+#: src/pyams_portal/interfaces/__init__.py:276
+msgid "Template name"
+msgstr "Nom du modèle"
+
+#: src/pyams_portal/interfaces/__init__.py:277
+msgid "Two registered templates can't share the same name..."
+msgstr "Deux modèles partagés ne peuvent pas utiliser le même nom..."
+
+#: src/pyams_portal/interfaces/__init__.py:309
+msgid "Inherit parent template?"
+msgstr "Hériter du modèle du parent ?"
+
+#: src/pyams_portal/interfaces/__init__.py:310
+msgid "Should we reuse parent template?"
+msgstr "Doit-on ré-utiliser le modèle du parent ?"
+
+#: src/pyams_portal/interfaces/__init__.py:314
+msgid "Use local template?"
+msgstr "Utiliser un modèle local ?"
+
+#: src/pyams_portal/interfaces/__init__.py:315
+msgid ""
+"If 'yes', you can define a custom local template instead of a shared template"
+msgstr ""
+"Si 'oui', vous pouvez définir un modèle de présentation local au lieu d'un "
+"modèle partagé"
+
+#: src/pyams_portal/interfaces/__init__.py:320
+msgid "Page template"
+msgstr "Modèle de page"
+
+#: src/pyams_portal/interfaces/__init__.py:321
+msgid "Template used for this page"
+msgstr "Modèle de présentation utilisé pour cette page"
+
+#: src/pyams_portal/interfaces/__init__.py:325
+msgid "Local template"
+msgstr "Modèle local"
+
+#~ msgid "Portlet templates"
+#~ msgstr "Modèles de présentation"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/locales/pyams_portal.pot	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,460 @@
+# 
+# SOME DESCRIPTIVE TITLE
+# This file is distributed under the same license as the PACKAGE package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, 2015.
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE 1.0\n"
+"POT-Creation-Date: 2015-05-12 13:59+0200\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Generated-By: Lingua 3.10.dev0\n"
+
+#: ./src/pyams_portal/workflow.py:41
+msgid "Draft"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:42
+msgid "Published"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:43
+msgid "Retired"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:44
+msgid "Archived"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:45
+msgid "Deleted"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:103
+msgid "Initialize"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:108
+msgid "Publish..."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:116
+msgid ""
+"This content is currently in DRAFT mode.\n"
+"                                               Publishing it will make it publicly visible."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:120
+msgid "Retire..."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:128
+msgid ""
+"This content is actually published.\n"
+"                                                 You can retire it to make it invisible, but contents using this\n"
+"                                                 template won't be visible anymore!"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:133 ./src/pyams_portal/workflow.py:181
+msgid "Create new version..."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:143
+msgid "Re-publish..."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:151
+msgid ""
+"This content was published and retired.\n"
+"                                                 You can re-publish it to make it visible again."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:155 ./src/pyams_portal/workflow.py:168
+msgid "Archive..."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:163
+msgid ""
+"This content is currently published.\n"
+"                                                  If it is archived, it will not be possible to make it visible again\n"
+"                                                  except by creating a new version!"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:176
+msgid ""
+"This content has been published but is currently retired.\n"
+"                                                If it is archived, it will not be possible to make it visible again\n"
+"                                                except by creating a new version!"
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:191
+msgid "Delete..."
+msgstr ""
+
+#: ./src/pyams_portal/workflow.py:199
+msgid ""
+"This content has never been published.\n"
+"                                    It can be removed and definitely deleted."
+msgstr ""
+
+#: ./src/pyams_portal/__init__.py:31
+msgid "Manage portal templates"
+msgstr ""
+
+#: ./src/pyams_portal/__init__.py:35
+msgid "Portal templates manager"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/portlet.py:41
+msgid "Edit portlet configuration"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/portlet.py:38
+#, python-format
+msgid "« {0} »  portal template - {1}"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:61
+msgid "Properties"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:72
+msgid "Portal template configuration"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:120
+msgid "Portlets configuration"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:133
+msgid "Add row..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:175
+msgid "Add slot..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:191
+msgid "Add slot"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:265
+msgid "Edit slot properties"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:333
+msgid "Add portlet..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:349
+msgid "Add portlet"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:209
+#: ./src/pyams_portal/zmi/template/__init__.py:269
+msgid "Specified name is already used!"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:118
+#: ./src/pyams_portal/zmi/template/config.py:189
+#: ./src/pyams_portal/zmi/template/config.py:347
+#, python-format
+msgid "« {0} »  portal template"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/config.py:262
+#, python-format
+msgid "« {0} »  portal template - {1} slot"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:109
+msgid "Publish template"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:151
+msgid "Retire template"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:180
+msgid "Archive template"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:209
+#: ./src/pyams_portal/zmi/template/workflow.py:201
+msgid "Create new version"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:100
+#: ./src/pyams_portal/zmi/template/workflow.py:142
+#: ./src/pyams_portal/zmi/template/workflow.py:171
+#: ./src/pyams_portal/zmi/template/workflow.py:200
+msgid "Close"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:101
+msgid "Publish"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:143
+msgid "Retire"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/workflow.py:172
+msgid "Archive"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:88
+#: ./src/pyams_portal/zmi/template/__init__.py:196
+#: ./src/pyams_portal/zmi/template/__init__.py:240
+msgid "Portal templates"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:97
+msgid "Shared portal templates"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:163
+msgid "Delete template"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:195
+msgid "Portal"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:229
+msgid "Add shared template..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:241
+msgid "Add shared template"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:153
+msgid "Older versions"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/__init__.py:212
+#: ./src/pyams_portal/zmi/template/__init__.py:146
+#, python-format
+msgid "Version {version} ({state} - last update {date})"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:15
+#: ./src/pyams_portal/zmi/template/templates/config.pt:29
+msgid "Version ${version} - ${state}"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:42
+msgid "Selected display:"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:47
+msgid "Current device"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:48
+msgid "Extra small device (phone)"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:49
+msgid "Small device (tablet)"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:50
+msgid "Medium desktop device (> 970px)"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:51
+msgid "Large desktop device (> 1170px)"
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:111
+msgid "Delete row..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:119
+msgid "Edit slot properties..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:126
+msgid "Delete slot..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:134
+msgid "Edit portlet properties..."
+msgstr ""
+
+#: ./src/pyams_portal/zmi/template/templates/config.pt:141
+msgid "Delete portlet..."
+msgstr ""
+
+#: ./src/pyams_portal/portlets/context/__init__.py:43
+msgid "Context content"
+msgstr ""
+
+#: ./src/pyams_portal/portlets/image/__init__.py:44
+msgid "Image"
+msgstr ""
+
+#: ./src/pyams_portal/portlets/image/interfaces.py:30
+msgid "Selected image"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:49
+msgid "Portlet"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:52
+#: ./src/pyams_portal/interfaces/__init__.py:63
+#: ./src/pyams_portal/interfaces/__init__.py:117
+msgid "Slot name"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:53
+#: ./src/pyams_portal/interfaces/__init__.py:64
+msgid "Slot name to which this configuration applies"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:69
+msgid "Position"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:70
+msgid "Portlet position inside slot"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:74
+msgid "Visible portlet?"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:75
+msgid ""
+"Select 'no' to hide this portlet. This will not break configuration "
+"inheritance..."
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:81
+#: ./src/pyams_portal/interfaces/__init__.py:139
+msgid "Inherit parent configuration?"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:82
+msgid ""
+"This option is only available if context's parent is using the same template "
+"and if this portlet is also present in the same slot..."
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:118
+msgid "This name must be unique in a given template"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:121
+msgid "Row ID"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:132
+msgid "Visible slot?"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:133
+msgid ""
+"Select 'no' to hide this slot. This will not break configuration "
+"inheritance..."
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:140
+msgid ""
+"This option is only available if context's parent template is using a "
+"template containing the same slot..."
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:145
+msgid "Extra small device width"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:146
+msgid ""
+"Slot width, in columns count, on extra small devices (phones...); set to 0 to"
+" hide the portlet"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:152
+msgid "Small device width"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:153
+msgid ""
+"Slot width, in columns count, on small devices (tablets...); set to 0 to hide"
+" the portlet"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:159
+msgid "Medium devices width"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:160
+msgid ""
+"Slot width, in columns count, on medium desktop devices (>= 992 pixels); set "
+"to 0 to hide the portlet"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:166
+msgid "Large devices width"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:167
+msgid ""
+"Slot width, in columns count, on large desktop devices (>= 1200 pixels); set "
+"to 0 to hide the portlet"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:173
+msgid "CSS class"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:174
+msgid "CSS class applied to this slot"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:276
+msgid "Template name"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:277
+msgid "Two registered templates can't share the same name..."
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:309
+msgid "Inherit parent template?"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:310
+msgid "Should we reuse parent template?"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:314
+msgid "Use local template?"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:315
+msgid ""
+"If 'yes', you can define a custom local template instead of a shared template"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:320
+msgid "Page template"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:321
+msgid "Template used for this page"
+msgstr ""
+
+#: ./src/pyams_portal/interfaces/__init__.py:325
+msgid "Local template"
+msgstr ""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/page.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,151 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyramid.view import view_config
+from zope.traversing.interfaces import ITraversable
+from pyams_portal.template import PortalWfTemplate, PortalTemplate
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.registry import query_utility
+from pyams_workflow.interfaces import IWorkflowInfo, IWorkflowVersions
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortalPage, IPortalContext, IPortalTemplateRenderer, \
+    IPortalTemplateConfiguration, IPortalWfTemplate
+from zope.annotation.interfaces import IAnnotations
+
+# import packages
+from persistent import Persistent
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyramid.threadlocal import get_current_registry
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.lifecycleevent import ObjectCreatedEvent, ObjectAddedEvent
+from zope.location.location import locate
+from zope.schema.fieldproperty import FieldProperty
+
+
+@implementer(IPortalPage)
+class PortalPage(Persistent, Contained):
+    """Portal page"""
+
+    _inherit_parent = FieldProperty(IPortalPage['inherit_parent'])
+    _use_local_template = FieldProperty(IPortalPage['use_local_template'])
+    _shared_template = FieldProperty(IPortalPage['shared_template'])
+    _local_template = FieldProperty(IPortalPage['local_template'])
+
+    @property
+    def can_inherit(self):
+        return IPortalContext.providedBy(self.__parent__.__parent__)
+
+    @property
+    def inherit_parent(self):
+        return self._inherit_parent if self.can_inherit else False
+
+    @inherit_parent.setter
+    def inherit_parent(self, value):
+        self._inherit_parent = value
+
+    @property
+    def use_local_template(self):
+        if self.inherit_parent:
+            return IPortalPage(self.__parent__.__parent__).use_local_template
+        else:
+            return self._use_local_template
+
+    @use_local_template.setter
+    def use_local_template(self, value):
+        self._use_local_template = value
+        if value and (self._local_template is None):
+            registry = get_current_registry()
+            wf_template = self._local_template = PortalWfTemplate()
+            registry.notify(ObjectCreatedEvent(wf_template))
+            locate(wf_template, self, '++template++')
+            template = PortalTemplate()
+            registry.notify(ObjectCreatedEvent(template))
+            IWorkflowVersions(wf_template).add_version(template, None)
+            IWorkflowInfo(template).fire_transition('init')
+
+    @property
+    def shared_template(self):
+        if self.inherit_parent:
+            return IPortalPage(self.__parent__.__parent__).shared_template
+        else:
+            return self._shared_template
+
+    @shared_template.setter
+    def shared_template(self, value):
+        if not self.inherit_parent:
+            if isinstance(value, IPortalWfTemplate):
+                value = value.__name__
+            self._shared_template = value
+
+    @property
+    def local_template(self):
+        if self.inherit_parent:
+            return IPortalPage(self.__parent__.__parent__).local_template
+        else:
+            return self._local_template
+
+    @local_template.setter
+    def local_template(self, value):
+        if not self.inherit_parent:
+            self._local_template = value
+
+    @property
+    def template(self):
+        if self.use_local_template:
+            return self.local_template
+        else:
+            template = self.shared_template
+            if isinstance(template, str):
+                template = query_utility(IPortalWfTemplate, name=template)
+            return template
+
+
+PORTAL_PAGE_KEY = 'pyams_portal.page'
+
+
+@adapter_config(name='template', context=IPortalContext, provides=ITraversable)
+class PortalPageTemplateTraverser(ContextAdapter):
+    """++template++ portal context traverser"""
+
+    def traverse(self, name, furtherpath=None):
+        page = IPortalPage(self.context)
+        if page.use_local_template:
+            return page.template
+
+
+@adapter_config(context=IPortalContext, provides=IPortalPage)
+def PortalPageFactory(context):
+    """Portal page factory"""
+    annotations = IAnnotations(context)
+    page = annotations.get(PORTAL_PAGE_KEY)
+    if page is None:
+        page = annotations[PORTAL_PAGE_KEY] = PortalPage()
+        get_current_registry().notify(ObjectCreatedEvent(page))
+        locate(page, context)
+    return page
+
+
+@view_config(context=IPortalContext, request_type=IPyAMSLayer)
+def PortalPageRenderer(request):
+    page = IPortalPage(request.context)
+    template = page.template
+    registry = request.registry
+    renderer = registry.queryMultiAdapter((request.context, request, template), IPortalTemplateRenderer)
+    if renderer is not None:
+        configuration = registry.queryMultiAdapter((request.context, template), IPortalTemplateConfiguration)
+        return renderer(configuration)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/portlet.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,160 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import logging
+logger = logging.getLogger('PyAMS (portal)')
+
+import venusian
+
+# import interfaces
+from pyams_portal.interfaces import IPortlet, IPortletRenderer, IPortletConfiguration, \
+    IPortalPage, IPortletPreviewer
+from zope.schema.interfaces import IVocabularyFactory
+
+# import packages
+from persistent import Persistent
+from pyams_utils.request import check_request
+from pyams_viewlet.viewlet import ContentProvider
+from pyramid.exceptions import ConfigurationError
+from zope.container.contained import Contained
+from zope.interface import implementer, provider
+from zope.schema.fieldproperty import FieldProperty
+from zope.schema.vocabulary import getVocabularyRegistry, SimpleVocabulary, SimpleTerm
+
+
+@implementer(IPortletConfiguration)
+class PortletConfiguration(Persistent, Contained):
+    """Portlet configuration"""
+
+    template = None
+    portlet_name = None
+    slot_name = FieldProperty(IPortletConfiguration['slot_name'])
+    position = FieldProperty(IPortletConfiguration['position'])
+    visible = FieldProperty(IPortletConfiguration['visible'])
+    _inherit_parent = FieldProperty(IPortletConfiguration['inherit_parent'])
+
+    def __init__(self, portlet):
+        self.portlet_name = portlet.name
+
+    @property
+    def can_inherit(self):
+        return IPortalPage.providedBy(self.__parent__)
+
+    @property
+    def inherit_parent(self):
+        return self._inherit_parent if self.can_inherit else False
+
+    @inherit_parent.setter
+    def inherit_parent(self, value):
+        self._inherit_parent = value
+
+
+@implementer(IPortlet)
+class Portlet(object):
+    """Base portlet content provider"""
+
+    permission = FieldProperty(IPortlet['permission'])
+
+    toolbar_image = None
+    toolbar_css_class = 'fa fa-fw fa-2x fa-edit'
+
+
+@provider(IVocabularyFactory)
+class PortletVocabulary(SimpleVocabulary):
+    """Portlet vocabulary"""
+
+    def __init__(self, context):
+        request = check_request()
+        translate = request.localizer.translate
+        utils = request.registry.getUtilitiesFor(IPortlet)
+        terms = [SimpleTerm(name, title=translate(util.label))
+                 for name, util in sorted(utils, key=lambda x: translate(x[1].label))]
+        super(PortletVocabulary, self).__init__(terms)
+
+getVocabularyRegistry().register('PyAMS portal portlets', PortletVocabulary)
+
+
+class PortletContentProvider(ContentProvider):
+    """Bae portlet content provider"""
+
+    def __init__(self, context, request, view, portlet_config):
+        super(PortletContentProvider, self).__init__(context, request, view)
+        self.__parent__ = view
+        self.configuration = portlet_config
+        self.portlet = self.request.registry.getUtility(IPortlet, name=portlet_config.portlet_name)
+
+    def __call__(self):
+        if self.portlet.permission and not self.request.has_permission(self.portlet.permission):
+            return ''
+        self.update()
+        return self.render()
+
+
+@implementer(IPortletPreviewer)
+class PortletPreviewer(PortletContentProvider):
+    """Portlet previewer adapter"""
+
+
+@implementer(IPortletRenderer)
+class PortletRenderer(PortletContentProvider):
+    """Portlet renderer adapter"""
+
+
+class portlet_config(object):
+    """Class decorator used to declare a portlet"""
+
+    venusian = venusian  # for testing injection
+
+    def __init__(self, **settings):
+        self.__dict__.update(settings)
+
+    def __call__(self, wrapped):
+        settings = self.__dict__.copy()
+        depth = settings.pop('_depth', 0)
+
+        def callback(context, name, ob):
+            name = settings.get('name') or getattr(ob, 'name', None)
+            if name is None:
+                raise ConfigurationError("You must provide a name for a portlet")
+
+            permission = settings.get('permission')
+            if permission is not None:
+                ob.permission = permission
+
+            if type(ob) is type:
+                factory = ob
+                component = None
+            else:
+                factory = None
+                component = ob
+
+            config = context.config.with_package(info.module)
+            logger.debug("Registering portlet {0} named '{1}'".format(str(component) if component else str(factory),
+                                                                      name))
+            config.registry.registerUtility(component=component, factory=factory,
+                                            provided=IPortlet, name=name)
+
+        info = self.venusian.attach(wrapped, callback, category='pyams_portal',
+                                    depth=depth + 1)
+        if info.scope == 'class':
+            # if the decorator was attached to a method in a class, or
+            # otherwise executed at class scope, we need to set an
+            # 'attr' into the settings if one isn't already in there
+            if settings.get('attr') is None:
+                settings['attr'] = wrapped.__name__
+
+        settings['_info'] = info.codeinfo  # fbo "action_method"
+        return wrapped
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/portlets/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/portlets/context/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,57 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from .interfaces import IContextPortletConfiguration
+from pyams_portal.interfaces import IPortletRenderer, IPortalContext
+from pyams_skin.layer import IPyAMSLayer
+
+# import packages
+from pyams_portal.portlet import Portlet, portlet_config, PortletRenderer, PortletConfiguration
+from pyams_template.template import template_config
+from pyams_utils.adapter import adapter_config
+from zope.interface import implementer, Interface
+
+from pyams_portal import _
+
+
+CONTEXT_PORTLET_NAME = 'pyams_portal.portlet.context'
+
+
+@portlet_config(permission='view')
+class ContextPortlet(Portlet):
+    """Context portlet
+
+    The goal of this portlet is to provide context content
+    """
+
+    name = CONTEXT_PORTLET_NAME
+    label = _("Context content")
+
+
+@adapter_config(context=ContextPortlet, provides=IContextPortletConfiguration)
+@implementer(IContextPortletConfiguration)
+class ContextPortletConfiguration(PortletConfiguration):
+    """Context portlet configuration"""
+
+
+@adapter_config(context=(IPortalContext, IPyAMSLayer, Interface, ContextPortlet), provides=IPortletRenderer)
+@template_config(template='context.pt', layer=IPyAMSLayer)
+class ContextPortletRenderer(PortletRenderer):
+    """Context portlet renderer"""
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/portlets/context/context.pt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+<h3>This is my context!!!</h3>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/portlets/context/interfaces.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,25 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortletConfiguration
+
+# import packages
+
+
+class IContextPortletConfiguration(IPortletConfiguration):
+    """Context portlet configuration interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/portlets/image/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,61 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_file.property import FileProperty
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from .interfaces import IImagePortletConfiguration
+from pyams_portal.interfaces import IPortalContext, IPortletRenderer
+from pyams_skin.layer import IPyAMSLayer
+
+# import packages
+from pyams_portal.portlet import portlet_config, Portlet, PortletConfiguration, PortletRenderer
+from pyams_template.template import template_config
+from pyams_utils.adapter import adapter_config
+from zope.interface import implementer, Interface
+
+from pyams_portal import _
+
+
+IMAGE_PORTLET_NAME = 'pyams_portal.portlet.image'
+
+
+@portlet_config(permission='view')
+class ImagePortlet(Portlet):
+    """Image portlet
+
+    The goal of this portlet is to display an image
+    """
+
+    name = IMAGE_PORTLET_NAME
+    label = _("Image")
+
+    toolbar_image = None
+    toolbar_css_class = 'fa fa-fw fa-2x fa-picture-o'
+
+
+@adapter_config(context=ImagePortlet, provides=IImagePortletConfiguration)
+@implementer(IImagePortletConfiguration)
+class ImagePortletConfiguration(PortletConfiguration):
+    """Image portlet configuration"""
+
+    image = FileProperty(IImagePortletConfiguration['image'])
+
+
+@adapter_config(context=(IPortalContext, IPyAMSLayer, Interface, ImagePortlet), provides=IPortletRenderer)
+@template_config(template='image.pt', layer=IPyAMSLayer)
+class ImagePortletRenderer(PortletRenderer):
+    """Image portlet renderer"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/portlets/image/interfaces.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,31 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_file.schema import ImageField
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortletConfiguration
+
+# import packages
+
+from pyams_portal import _
+
+
+class IImagePortletConfiguration(IPortletConfiguration):
+    """Image portlet configuration interface"""
+
+    image = ImageField(title=_("Selected image"),
+                       required=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/resources/css/portal.css	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,133 @@
+#portal_config .rows {
+  min-height: 15px;
+}
+#portal_config .row {
+  position: relative;
+  margin: 5px 0;
+  padding: 2px 4px;
+  border: 1px solid rgba(199, 81, 0, 0.4);
+  border-top-width: 1.5em;
+  min-height: 20px;
+  cursor: move;
+}
+#portal_config .row > .row_id {
+  position: absolute;
+  right: 2px;
+  top: -1.6em;
+}
+#portal_config .row .slot {
+  margin: 3px 0;
+  padding: 3px;
+  border: 1px solid rgba(98, 120, 128, 0.65);
+  border-bottom-width: 6px;
+  min-height: 20px!important;
+}
+#portal_config .row .slot > .header {
+  background-color: rgba(98, 120, 128, 0.6);
+  color: white;
+}
+#portal_config .row .portlet {
+  margin: 3px 0;
+  padding: 3px;
+  border: 1px solid rgba(98, 120, 128, 0.6);
+  min-height: 20px!important;
+}
+#portal_config .row .portlet > .header {
+  background-color: rgba(92, 109, 115, 0.8);
+  color: white;
+}
+#portal_config .row-highlight {
+  margin: 5px 0;
+  border: 1px solid #c75100;
+  min-height: 40px;
+}
+#portal_config .slots {
+  min-height: 15px;
+}
+#portal_config .slot-highlight {
+  margin: 3px 0;
+  border: 1px solid #7b939c;
+  min-height: 40px;
+}
+#portal_config .portlets {
+  min-height: 15px;
+}
+#portal_config .portlets-hover {
+  background-color: silver;
+}
+#portal_config .portlets-active {
+  background-color: silver;
+}
+#portal_config .portlet-highlight {
+  margin: 0;
+  border: 1px solid #7b939c;
+  min-height: 40px;
+}
+#portal_config.container .col-12 {
+  float: left;
+  width: 100%!important;
+}
+#portal_config.container .col-11 {
+  float: left;
+  width: 91.66666667%!important;
+}
+#portal_config.container .col-10 {
+  float: left;
+  width: 83.33333333%!important;
+}
+#portal_config.container .col-9 {
+  float: left;
+  width: 75%!important;
+}
+#portal_config.container .col-8 {
+  float: left;
+  width: 66.66666667%!important;
+}
+#portal_config.container .col-7 {
+  float: left;
+  width: 58.33333333%!important;
+}
+#portal_config.container .col-6 {
+  float: left;
+  width: 50%!important;
+}
+#portal_config.container .col-5 {
+  float: left;
+  width: 41.66666667%!important;
+}
+#portal_config.container .col-4 {
+  float: left;
+  width: 33.33333333%!important;
+}
+#portal_config.container .col-3 {
+  float: left;
+  width: 25%!important;
+}
+#portal_config.container .col-2 {
+  float: left;
+  width: 16.66666667%!important;
+}
+#portal_config.container .col-1 {
+  float: left;
+  width: 8.33333333%!important;
+}
+#portal_config.container .col-0 {
+  float: left;
+  width: 100%!important;
+  opacity: 0.5;
+}
+#portal_config.container .col-0 > .portlets {
+  display: none;
+}
+#portal_config.container-xs {
+  max-width: 750px!important;
+}
+#portal_config.container-sm {
+  width: 750px!important;
+}
+#portal_config.container-md {
+  width: 970px!important;
+}
+#portal_config.container-lg {
+  width: 1170px!important;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/resources/js/portal.js	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,495 @@
+(function($) {
+
+	window.PyAMS_portal = {
+
+		/**
+		 * Templates management
+		 */
+		template: {
+
+			initConfig: function() {
+				var config = $('#portal_config');
+				if (config.data('ams-allowed-change')) {
+					// Init sortables and resizables
+					$('.rows', config).addClass('sortable');
+					$('.slots', config).addClass('sortable');
+					$('.slot', config).addClass('resizable');
+					$('.portlets', config).addClass('sortable');
+					MyAMS.plugins.enabled.sortable(config);
+					MyAMS.plugins.enabled.resizable(config);
+					// Init rows toolbar drag and drop
+					$('.btn-row', '.btn-toolbar').draggable({
+						cursor: 'move',
+						helper: 'clone',
+						revert: 'invalid',
+						connectToSortable: '.rows'
+					});
+					$('.rows', config).droppable({
+						accept: '.btn-row',
+						drop: function(event, ui) {
+							if (ui.draggable.hasClass('already-dropped'))
+								return;
+							ui.draggable.addClass('already-dropped');
+							MyAMS.ajax.post('add-template-row.json', {}, function(result) {
+								var row_id = result.row_id;
+								var rows = $('.rows', '#portal_config');
+								ui.draggable.removeClassPrefix('btn')
+											.removeClassPrefix('ui-')
+											.removeClass('already-dropped')
+											.removeAttr('style')
+											.addClass('row context-menu')
+											.attr('data-ams-row-id', row_id)
+											.empty()
+											.append($('<span></span>').addClass('row_id label label-success pull-right')
+																	  .text(row_id))
+											.append($('<div></div>').addClass('slots')
+																	.sortable({
+																		placeholder: 'slot-highlight',
+																		connectWith: '.slots',
+																		over: PyAMS_portal.template.overSlots,
+																		stop: PyAMS_portal.template.sortSlots
+																	}))
+											.contextMenu({
+												menuSelector: '#rowMenu',
+												menuSelected: MyAMS.helpers.contextMenuHandler
+											});
+								PyAMS_portal.template.sortRows();
+								rows.sortable('refresh');
+							});
+						}
+					});
+					// Init slot toolbar drag and drop
+					$('.btn-slot', '.btn-toolbar').draggable({
+						cursor: 'move',
+						helper: 'clone',
+						revert: 'invalid',
+						connectToSortable: '.slots'
+					});
+					$('.slots', config).droppable({
+						accept: '.btn-slot',
+						drop: function(event, ui) {
+							if (ui.draggable.hasClass('already-dropped'))
+								return;
+							ui.draggable.addClass('already-dropped');
+							var row_id = ui.helper.parents('.row:first').data('ams-row-id');
+							MyAMS.dialog.open('add-template-slot.html?form.widgets.row_id=' + row_id);
+						}
+					});
+					// Init portlets toolbar drag and drop
+					$('.btn-portlet', '.btn-toolbar').draggable({
+						cursor: 'move',
+						helper: 'clone',
+						revert: 'invalid',
+						connectToSortable: '.portlets'
+					});
+					$('.portlets', config).droppable({
+						accept: '.btn-portlet',
+						hoverClass: 'portlets-hover',
+						activeClass: 'portlets-active',
+						drop: function(event, ui) {
+							if (ui.draggable.hasClass('already-dropped'))
+								return;
+							ui.draggable.addClass('already-dropped');
+							var source = ui.draggable;
+							var target = $(this);
+							var slot = target.parents('.slot:first');
+							MyAMS.ajax.post('drag-template-portlet.json', {
+								portlet_name: source.data('ams-portlet-name'),
+								slot_name: slot.data('ams-slot-name')
+							}, function(result) {
+								MyAMS.ajax.handleJSON(result);
+							});
+						}
+					});
+				}
+			},
+
+
+			/**
+			 * Display selector
+			 */
+
+			selectDisplay: function() {
+				var device = $(this).val();
+				MyAMS.ajax.post('get-slots-width.json',
+								{device: device},
+								function(result) {
+									var config = $('#portal_config');
+									config.removeClassPrefix('container-');
+									if (device) {
+										config.addClass('container-' + device);
+									}
+									$('.slot', config).removeClassPrefix('col-');
+									for (var slot_name in result) {
+										var widths = result[slot_name];
+										var slot = $('.slot[data-ams-slot-name="' + slot_name + '"]', config);
+										if (device) {
+											slot.addClass('col-' + widths[device]);
+										} else {
+											for (var display in widths) {
+												slot.addClass('col-' + display + '-' + widths[display]);
+											}
+										}
+									}
+								});
+			},
+
+			/**
+			 * Rows management
+			 */
+
+			addRow: function() {
+				return function() {
+					$(this).parents('.btn-group').removeClass('open');
+					MyAMS.ajax.post('add-template-row.json', {}, function(result) {
+						var row_id = result.row_id;
+						var rows = $('.rows', '#portal_config');
+						$('<div></div>').addClass('row context-menu')
+										.attr('data-ams-row-id', row_id)
+										.append($('<span></span>').addClass('row_id label label-success pull-right')
+																  .text(row_id))
+										.append($('<div></div>').addClass('slots')
+																.sortable({
+																	placeholder: 'slot-highlight',
+																	connectWith: '.slots',
+																	over: PyAMS_portal.template.overSlots,
+																	stop: PyAMS_portal.template.sortSlots
+																}))
+										.contextMenu({
+											menuSelector: '#rowMenu',
+											menuSelected: MyAMS.helpers.contextMenuHandler
+										})
+										.appendTo(rows);
+						rows.sortable('refresh');
+					});
+				};
+			},
+
+			overRows: function(event, ui) {
+				$(ui.placeholder).attr('class', $(ui.item).attr('class'))
+								 .removeClassPrefix('ui-')
+								 .addClass('row-highlight')
+								 .css('height', $(ui.item).outerHeight());
+			},
+
+			sortRows: function(event, ui) {
+				if (ui && ui.item.hasClass('already-dropped'))
+					return;
+				var config = $('#portal_config');
+				var ids = $('.row', config).listattr('data-ams-row-id');
+				MyAMS.ajax.post('set-template-row-order.json',
+								{rows: JSON.stringify(ids)},
+								function(result) {
+									if (result.status == 'success') {
+										$('.row', config).each(function (index) {
+											$(this).attr('data-ams-row-id', index);
+											$('span.row_id', $(this)).text(index);
+										})
+									}
+								});
+			},
+
+			deleteRow: function() {
+				return function(row) {
+					MyAMS.skin.bigBox({
+						title: MyAMS.i18n.WARNING,
+						content: '<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; ' + MyAMS.i18n.DELETE_WARNING,
+						buttons: MyAMS.i18n.BTN_OK_CANCEL
+					}, function(button) {
+						if (button == MyAMS.i18n.BTN_OK) {
+							if (!row.hasClass('row'))
+								row = row.parents('.row');
+							MyAMS.ajax.post('delete-template-row.json',
+											{row_id: row.data('ams-row-id')},
+											function(result) {
+												if (result.status == 'success') {
+													row.remove();
+													$('.row', '#portal_config').each(function (index) {
+														$(this).removeData()
+															   .attr('data-ams-row-id', index);
+														$('span.row_id', $(this)).text(index);
+													})
+												}
+											});
+						}
+					});
+				};
+			},
+
+
+			/**
+			 * Slots management
+			 */
+
+			addSlotCallback: function(result) {
+				var slots = $('.slots', '.row[data-ams-row-id="' + result.row_id + '"]');
+				var slot_name = result.slot_name;
+				var new_slot = $('<div></div>').addClass('slot context-menu col col-md-12')
+											   .attr('data-ams-slot-name', slot_name)
+											   .append($('<div></div>').addClass('header padding-x-5')
+																	   .text(slot_name))
+											   .append($('<div></div>').addClass('portlets')
+																	   .sortable({
+																			placeholder: 'portlet-highlight',
+																			connectWith: '.portlets',
+																			over: PyAMS_portal.template.overPortlets,
+																			stop: PyAMS_portal.template.sortPortlets
+																	   }))
+											   .append($('<div></div>').addClass('clearfix'))
+											   .contextMenu({
+													menuSelector: '#slotMenu',
+													menuSelected: MyAMS.helpers.contextMenuHandler
+											   });
+				var slot_button = $('.btn-slot', slots);
+				if (slot_button.exists()) {  // Slot added via drag & drop
+					slot_button.replaceWith(new_slot);
+					$('.slot', slots).each(function() {
+						$(this).removeData();
+					});
+					PyAMS_portal.template.sortSlots();
+				} else {
+					new_slot.appendTo(slots);
+				}
+				slots.sortable('refresh');
+			},
+
+			startSlotResize: function(event, ui) {
+				var slot = ui.element;
+				var row = slot.parents('.slots:first');
+				var colWidth = (row.innerWidth() - 110) / 12;
+				var slotHeight = slot.height();
+				ui.element.resizable('option', 'grid', [colWidth, slotHeight]);
+				ui.element.resizable('option', 'minWidth', colWidth);
+				ui.element.resizable('option', 'minHeight', slotHeight);
+				ui.element.resizable('option', 'maxWidth', row.innerWidth());
+				ui.element.resizable('option', 'maxHeight', slotHeight);
+			},
+
+			stopSlotResize: function(event, ui) {
+				var slot = ui.element;
+				var row = slot.parents('.slots:first');
+				var colWidth = (row.innerWidth() - 10) / 12;
+				var slotCols = Math.round($(slot).width() / colWidth);
+				var device = $('#device_selector').val();
+				if (!device) {
+					var deviceWidth = $('body').width();
+					if (deviceWidth > 1170)
+						device = 'lg';
+					else if (deviceWidth > 970)
+						device = 'md';
+					else if (deviceWidth > 750)
+						device = 'sm';
+					else
+						device = 'xs';
+				}
+				MyAMS.ajax.post('set-slot-width.json',
+								{slot_name: slot.data('ams-slot-name'),
+								 device: device,
+								 width: slotCols},
+								function(result) {
+									slot.removeClassPrefix('col-');
+									slot.removeAttr('style');
+									var slot_name = slot.data('ams-slot-name');
+									var widths = result[slot_name];
+									if (device) {
+										slot.addClass('col-' + device + '-' + widths[device]);
+									} else {
+										slot.addClass('col-' + widths[device]);
+									}
+								});
+			},
+
+			editSlot: function() {
+				return function(slot) {
+					if (!slot.hasClass('slot'))
+						slot = slot.parents('.slot');
+					MyAMS.dialog.open('slot-properties.html?form.widgets.slot_name=' + slot.data('ams-slot-name'));
+				};
+			},
+
+			editSlotCallback: function(result) {
+				var slot = $('.slot[data-ams-slot-name="' + result.slot_name + '"]');
+				slot.attr('class', 'slot context-menu col');
+				var device = $('#device_selector').val();
+				if (device)
+					slot.addClass('col-' + result.width[device]);
+				else {
+					for (device in result.width) {
+						slot.addClass('col-' + device + '-' + result.width[device]);
+					}
+				}
+			},
+
+			overSlots: function(event, ui) {
+				$(ui.placeholder).attr('class', $(ui.item).attr('class'))
+								 .removeClassPrefix('ui-')
+								 .addClass('slot-highlight')
+								 .css('height', $(ui.item).outerHeight());
+			},
+
+			sortSlots: function(event, ui) {
+				if (ui && ui.item.hasClass('already-dropped'))
+					return;
+				var config = $('#portal_config');
+				var order = {};
+				$('.row', config).each(function() {
+					var row = $(this);
+					var row_config = [];
+					$('.slot', row).each(function() {
+						row_config.push($(this).data('ams-slot-name'));
+					});
+					order[parseInt(row.attr('data-ams-row-id'))] = row_config;
+				});
+				MyAMS.ajax.post('set-template-slot-order.json',
+								{order: JSON.stringify(order)},
+								function(result) {
+									if (result.status == 'success') {}
+								});
+			},
+
+			deleteSlot: function() {
+				return function(slot) {
+					MyAMS.skin.bigBox({
+						title: MyAMS.i18n.WARNING,
+						content: '<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; ' + MyAMS.i18n.DELETE_WARNING,
+						buttons: MyAMS.i18n.BTN_OK_CANCEL
+					}, function(button) {
+						if (button == MyAMS.i18n.BTN_OK) {
+							if (!slot.hasClass('slot'))
+								slot = slot.parents('.slot');
+							MyAMS.ajax.post('delete-template-slot.json',
+											{slot_name: slot.data('ams-slot-name')},
+											function(result) {
+												if (result.status == 'success') {
+													slot.remove();
+													$('.slot', '#portal_config').each(function() {
+														$(this).removeData();
+													});
+												}
+											});
+						}
+					});
+				};
+			},
+
+
+			/**
+			 * Portlets management
+			 */
+
+			addPortletCallback: function(result) {
+				var portlets = $('.portlets', '.slot[data-ams-slot-name="' + result.slot_name + '"]');
+				var portlet = $('<div></div>').addClass('portlet context-menu')
+											  .attr('data-ams-portlet-name', result.portlet_name)
+											  .attr('data-ams-portlet-slot', result.slot_name)
+											  .attr('data-ams-portlet-position', result.position)
+											  .append($('<div></div>').addClass('header padding-x-5')
+																	  .text(result.label))
+											.  append($('<div></div>').addClass('preview')
+																	  .html(result.preview || ''))
+											  .contextMenu({
+													menuSelector: '#portletMenu',
+													menuSelected: MyAMS.helpers.contextMenuHandler
+											  });
+				MyAMS.initContent($('.preview', portlet));
+				var portlet_button = $('.btn-portlet', portlets);
+				if (portlet_button.exists()) {  // Portlet added via drag & drop
+					portlet_button.replaceWith(portlet);
+					$('.portlet', portlets).each(function() {
+						$(this).removeData();
+					});
+					PyAMS_portal.template.sortPortlets(null, {item: portlet});
+				} else {
+					portlet.appendTo(portlets);
+				}
+				portlets.sortable('refresh');
+			},
+
+			editPortlet: function() {
+				return function(portlet) {
+					if (!portlet.hasClass('portlet'))
+						portlet = portlet.parents('.portlet:first');
+					var slot = portlet.parents('.slot:first');
+					var row = slot.parents('.row:first');
+					MyAMS.dialog.open('portlet-properties.html?form.widgets.slot_name=' + slot.data('ams-slot-name') +
+															 '&form.widgets.position=' + portlet.data('ams-portlet-position'));
+				};
+			},
+
+			editPortletCallback: function(result) {
+				if (result.preview) {
+					var config = $('#portal_config');
+					var portlet = $('.portlet[data-ams-portlet-slot="' + result.slot_name + '"]' +
+											'[data-ams-portlet-position="' + result.position + '"]', config);
+					$('.preview', portlet).html(result.preview);
+					MyAMS.initContent($('.preview', portlet));
+				}
+			},
+
+			overPortlets: function(event, ui) {
+				$(ui.placeholder).attr('class', $(ui.item).attr('class'))
+								 .removeClassPrefix('ui-')
+								 .addClass('portlet-highlight')
+								 .css('height', $(ui.item).outerHeight());
+			},
+
+			sortPortlets: function(event, ui) {
+				if (ui.item.hasClass('already-dropped'))
+					return;
+				var portlet = ui.item;
+				var to_slot = portlet.parents('.slot');
+				var to_portlets = $('.portlet', to_slot);
+				var order = {from: {name: portlet.data('ams-portlet-name'),
+									slot: portlet.data('ams-portlet-slot'),
+									position: portlet.data('ams-portlet-position')},
+							 to: {slot: to_slot.data('ams-slot-name'),
+								  names: to_portlets.listattr('data-ams-portlet-name'),
+								  slots: to_portlets.listattr('data-ams-portlet-slot'),
+								  positions: to_portlets.listattr('data-ams-portlet-position')}};
+				MyAMS.ajax.post('set-template-portlet-order.json',
+								{order: JSON.stringify(order)},
+								function(result) {
+									if (result.status == 'success') {
+										var from_slot = $('.slot[data-ams-slot-name="' + portlet.attr('data-ams-portlet-slot') + '"]', '#portal_config');
+										$('.portlet', from_slot).each(function(index) {
+											$(this).removeData()
+												   .attr('data-ams-portlet-position', index);
+										});
+										$('.portlet', to_slot).each(function(index) {
+											$(this).removeData()
+												   .attr('data-ams-portlet-slot', to_slot.attr('data-ams-slot-name'))
+												   .attr('data-ams-portlet-position', index);
+										});
+									}
+								});
+			},
+
+			deletePortlet: function() {
+				return function(portlet) {
+					MyAMS.skin.bigBox({
+						title: MyAMS.i18n.WARNING,
+						content: '<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; ' + MyAMS.i18n.DELETE_WARNING,
+						buttons: MyAMS.i18n.BTN_OK_CANCEL
+					}, function(button) {
+						if (button == MyAMS.i18n.BTN_OK) {
+							if (!portlet.hasClass('portlet'))
+								portlet = portlet.parents('.portlet');
+							MyAMS.ajax.post('delete-template-portlet.json',
+											{slot_name: portlet.data('ams-portlet-slot'),
+											 position: portlet.data('ams-portlet-position')},
+											function(result) {
+												if (result.status == 'success') {
+													portlet.remove();
+													$('.portlet', '#portal_config').each(function() {
+														$(this).removeData();
+													});
+												}
+											});
+						}
+					});
+				};
+			}
+		}
+	};
+
+})(jQuery);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/resources/js/portal.min.js	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+(function(a){window.PyAMS_portal={template:{initConfig:function(){var b=a("#portal_config");if(b.data("ams-allowed-change")){a(".rows",b).addClass("sortable");a(".slots",b).addClass("sortable");a(".slot",b).addClass("resizable");a(".portlets",b).addClass("sortable");MyAMS.plugins.enabled.sortable(b);MyAMS.plugins.enabled.resizable(b);a(".btn-row",".btn-toolbar").draggable({cursor:"move",helper:"clone",revert:"invalid",connectToSortable:".rows"});a(".rows",b).droppable({accept:".btn-row",drop:function(c,d){if(d.draggable.hasClass("already-dropped")){return}d.draggable.addClass("already-dropped");MyAMS.ajax.post("add-template-row.json",{},function(e){var f=e.row_id;var g=a(".rows","#portal_config");d.draggable.removeClassPrefix("btn").removeClassPrefix("ui-").removeClass("already-dropped").removeAttr("style").addClass("row context-menu").attr("data-ams-row-id",f).empty().append(a("<span></span>").addClass("row_id label label-success pull-right").text(f)).append(a("<div></div>").addClass("slots").sortable({placeholder:"slot-highlight",connectWith:".slots",over:PyAMS_portal.template.overSlots,stop:PyAMS_portal.template.sortSlots})).contextMenu({menuSelector:"#rowMenu",menuSelected:MyAMS.helpers.contextMenuHandler});PyAMS_portal.template.sortRows();g.sortable("refresh")})}});a(".btn-slot",".btn-toolbar").draggable({cursor:"move",helper:"clone",revert:"invalid",connectToSortable:".slots"});a(".slots",b).droppable({accept:".btn-slot",drop:function(d,e){if(e.draggable.hasClass("already-dropped")){return}e.draggable.addClass("already-dropped");var c=e.helper.parents(".row:first").data("ams-row-id");MyAMS.dialog.open("add-template-slot.html?form.widgets.row_id="+c)}});a(".btn-portlet",".btn-toolbar").draggable({cursor:"move",helper:"clone",revert:"invalid",connectToSortable:".portlets"});a(".portlets",b).droppable({accept:".btn-portlet",hoverClass:"portlets-hover",activeClass:"portlets-active",drop:function(c,e){if(e.draggable.hasClass("already-dropped")){return}e.draggable.addClass("already-dropped");var d=e.draggable;var f=a(this);var g=f.parents(".slot:first");MyAMS.ajax.post("drag-template-portlet.json",{portlet_name:d.data("ams-portlet-name"),slot_name:g.data("ams-slot-name")},function(h){MyAMS.ajax.handleJSON(h)})}})}},selectDisplay:function(){var b=a(this).val();MyAMS.ajax.post("get-slots-width.json",{device:b},function(c){var d=a("#portal_config");d.removeClassPrefix("container-");if(b){d.addClass("container-"+b)}a(".slot",d).removeClassPrefix("col-");for(var e in c){var f=c[e];var h=a('.slot[data-ams-slot-name="'+e+'"]',d);if(b){h.addClass("col-"+f[b])}else{for(var g in f){h.addClass("col-"+g+"-"+f[g])}}}})},addRow:function(){return function(){a(this).parents(".btn-group").removeClass("open");MyAMS.ajax.post("add-template-row.json",{},function(b){var c=b.row_id;var d=a(".rows","#portal_config");a("<div></div>").addClass("row context-menu").attr("data-ams-row-id",c).append(a("<span></span>").addClass("row_id label label-success pull-right").text(c)).append(a("<div></div>").addClass("slots").sortable({placeholder:"slot-highlight",connectWith:".slots",over:PyAMS_portal.template.overSlots,stop:PyAMS_portal.template.sortSlots})).contextMenu({menuSelector:"#rowMenu",menuSelected:MyAMS.helpers.contextMenuHandler}).appendTo(d);d.sortable("refresh")})}},overRows:function(b,c){a(c.placeholder).attr("class",a(c.item).attr("class")).removeClassPrefix("ui-").addClass("row-highlight").css("height",a(c.item).outerHeight())},sortRows:function(d,e){if(e&&e.item.hasClass("already-dropped")){return}var b=a("#portal_config");var c=a(".row",b).listattr("data-ams-row-id");MyAMS.ajax.post("set-template-row-order.json",{rows:JSON.stringify(c)},function(f){if(f.status=="success"){a(".row",b).each(function(g){a(this).attr("data-ams-row-id",g);a("span.row_id",a(this)).text(g)})}})},deleteRow:function(){return function(b){MyAMS.skin.bigBox({title:MyAMS.i18n.WARNING,content:'<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; '+MyAMS.i18n.DELETE_WARNING,buttons:MyAMS.i18n.BTN_OK_CANCEL},function(c){if(c==MyAMS.i18n.BTN_OK){if(!b.hasClass("row")){b=b.parents(".row")}MyAMS.ajax.post("delete-template-row.json",{row_id:b.data("ams-row-id")},function(d){if(d.status=="success"){b.remove();a(".row","#portal_config").each(function(e){a(this).removeData().attr("data-ams-row-id",e);a("span.row_id",a(this)).text(e)})}})}})}},addSlotCallback:function(b){var e=a(".slots",'.row[data-ams-row-id="'+b.row_id+'"]');var d=b.slot_name;var c=a("<div></div>").addClass("slot context-menu col col-md-12").attr("data-ams-slot-name",d).append(a("<div></div>").addClass("header padding-x-5").text(d)).append(a("<div></div>").addClass("portlets").sortable({placeholder:"portlet-highlight",connectWith:".portlets",over:PyAMS_portal.template.overPortlets,stop:PyAMS_portal.template.sortPortlets})).append(a("<div></div>").addClass("clearfix")).contextMenu({menuSelector:"#slotMenu",menuSelected:MyAMS.helpers.contextMenuHandler});var f=a(".btn-slot",e);if(f.exists()){f.replaceWith(c);a(".slot",e).each(function(){a(this).removeData()});PyAMS_portal.template.sortSlots()}else{c.appendTo(e)}e.sortable("refresh")},startSlotResize:function(c,e){var g=e.element;var f=g.parents(".slots:first");var b=(f.innerWidth()-110)/12;var d=g.height();e.element.resizable("option","grid",[b,d]);e.element.resizable("option","minWidth",b);e.element.resizable("option","minHeight",d);e.element.resizable("option","maxWidth",f.innerWidth());e.element.resizable("option","maxHeight",d)},stopSlotResize:function(e,g){var i=g.element;var h=i.parents(".slots:first");var c=(h.innerWidth()-10)/12;var f=Math.round(a(i).width()/c);var d=a("#device_selector").val();if(!d){var b=a("body").width();if(b>1170){d="lg"}else{if(b>970){d="md"}else{if(b>750){d="sm"}else{d="xs"}}}}MyAMS.ajax.post("set-slot-width.json",{slot_name:i.data("ams-slot-name"),device:d,width:f},function(j){i.removeClassPrefix("col-");i.removeAttr("style");var k=i.data("ams-slot-name");var l=j[k];if(d){i.addClass("col-"+d+"-"+l[d])}else{i.addClass("col-"+l[d])}})},editSlot:function(){return function(b){if(!b.hasClass("slot")){b=b.parents(".slot")}MyAMS.dialog.open("slot-properties.html?form.widgets.slot_name="+b.data("ams-slot-name"))}},editSlotCallback:function(b){var d=a('.slot[data-ams-slot-name="'+b.slot_name+'"]');d.attr("class","slot context-menu col");var c=a("#device_selector").val();if(c){d.addClass("col-"+b.width[c])}else{for(c in b.width){d.addClass("col-"+c+"-"+b.width[c])}}},overSlots:function(b,c){a(c.placeholder).attr("class",a(c.item).attr("class")).removeClassPrefix("ui-").addClass("slot-highlight").css("height",a(c.item).outerHeight())},sortSlots:function(d,e){if(e&&e.item.hasClass("already-dropped")){return}var c=a("#portal_config");var b={};a(".row",c).each(function(){var g=a(this);var f=[];a(".slot",g).each(function(){f.push(a(this).data("ams-slot-name"))});b[parseInt(g.attr("data-ams-row-id"))]=f});MyAMS.ajax.post("set-template-slot-order.json",{order:JSON.stringify(b)},function(f){if(f.status=="success"){}})},deleteSlot:function(){return function(b){MyAMS.skin.bigBox({title:MyAMS.i18n.WARNING,content:'<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; '+MyAMS.i18n.DELETE_WARNING,buttons:MyAMS.i18n.BTN_OK_CANCEL},function(c){if(c==MyAMS.i18n.BTN_OK){if(!b.hasClass("slot")){b=b.parents(".slot")}MyAMS.ajax.post("delete-template-slot.json",{slot_name:b.data("ams-slot-name")},function(d){if(d.status=="success"){b.remove();a(".slot","#portal_config").each(function(){a(this).removeData()})}})}})}},addPortletCallback:function(b){var c=a(".portlets",'.slot[data-ams-slot-name="'+b.slot_name+'"]');var e=a("<div></div>").addClass("portlet context-menu").attr("data-ams-portlet-name",b.portlet_name).attr("data-ams-portlet-slot",b.slot_name).attr("data-ams-portlet-position",b.position).append(a("<div></div>").addClass("header padding-x-5").text(b.label)).append(a("<div></div>").addClass("preview").html(b.preview||"")).contextMenu({menuSelector:"#portletMenu",menuSelected:MyAMS.helpers.contextMenuHandler});MyAMS.initContent(a(".preview",e));var d=a(".btn-portlet",c);if(d.exists()){d.replaceWith(e);a(".portlet",c).each(function(){a(this).removeData()});PyAMS_portal.template.sortPortlets(null,{item:e})}else{e.appendTo(c)}c.sortable("refresh")},editPortlet:function(){return function(c){if(!c.hasClass("portlet")){c=c.parents(".portlet:first")}var d=c.parents(".slot:first");var b=d.parents(".row:first");MyAMS.dialog.open("portlet-properties.html?form.widgets.slot_name="+d.data("ams-slot-name")+"&form.widgets.position="+c.data("ams-portlet-position"))}},editPortletCallback:function(b){if(b.preview){var c=a("#portal_config");var d=a('.portlet[data-ams-portlet-slot="'+b.slot_name+'"][data-ams-portlet-position="'+b.position+'"]',c);a(".preview",d).html(b.preview);MyAMS.initContent(a(".preview",d))}},overPortlets:function(b,c){a(c.placeholder).attr("class",a(c.item).attr("class")).removeClassPrefix("ui-").addClass("portlet-highlight").css("height",a(c.item).outerHeight())},sortPortlets:function(c,f){if(f.item.hasClass("already-dropped")){return}var g=f.item;var e=g.parents(".slot");var d=a(".portlet",e);var b={from:{name:g.data("ams-portlet-name"),slot:g.data("ams-portlet-slot"),position:g.data("ams-portlet-position")},to:{slot:e.data("ams-slot-name"),names:d.listattr("data-ams-portlet-name"),slots:d.listattr("data-ams-portlet-slot"),positions:d.listattr("data-ams-portlet-position")}};MyAMS.ajax.post("set-template-portlet-order.json",{order:JSON.stringify(b)},function(h){if(h.status=="success"){var i=a('.slot[data-ams-slot-name="'+g.attr("data-ams-portlet-slot")+'"]',"#portal_config");a(".portlet",i).each(function(j){a(this).removeData().attr("data-ams-portlet-position",j)});a(".portlet",e).each(function(j){a(this).removeData().attr("data-ams-portlet-slot",e.attr("data-ams-slot-name")).attr("data-ams-portlet-position",j)})}})},deletePortlet:function(){return function(b){MyAMS.skin.bigBox({title:MyAMS.i18n.WARNING,content:'<i class="text-danger fa fa-2x fa-bell shake animated"></i>&nbsp; '+MyAMS.i18n.DELETE_WARNING,buttons:MyAMS.i18n.BTN_OK_CANCEL},function(c){if(c==MyAMS.i18n.BTN_OK){if(!b.hasClass("portlet")){b=b.parents(".portlet")}MyAMS.ajax.post("delete-template-portlet.json",{slot_name:b.data("ams-portlet-slot"),position:b.data("ams-portlet-position")},function(d){if(d.status=="success"){b.remove();a(".portlet","#portal_config").each(function(){a(this).removeData()})}})}})}}}}})(jQuery);
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/resources/less/portal.less	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,146 @@
+#portal_config {
+
+	.rows {
+		min-height: 15px;
+	}
+	.row {
+		position: relative;
+		margin: 5px 0;
+		padding: 2px 4px;
+		border: 1px solid rgba(199, 81, 0, 0.40);
+		border-top-width: 1.5em;
+		min-height: 20px;
+		cursor: move;
+
+		> .row_id {
+			position: absolute;
+			right: 2px;
+			top: -1.6em;
+		}
+		.slot {
+			margin: 3px 0;
+			padding: 3px;
+			border: 1px solid rgba(98, 120, 128, 0.65);
+			border-bottom-width: 6px;
+			min-height: 20px!important;
+
+			>.header {
+				background-color: rgba(98, 120, 128, 0.60);
+				color: white;
+			}
+		}
+		.portlet {
+			margin: 3px 0;
+			padding: 3px;
+			border: 1px solid rgba(98, 120, 128, 0.60);
+			min-height: 20px!important;
+
+			>.header {
+				background-color: rgba(92, 109, 115, 0.80);
+				color: white;
+			}
+		}
+	}
+	.row-highlight {
+		margin: 5px 0;
+		border: 1px solid #c75100;
+		min-height: 40px;
+	}
+
+	.slots {
+		min-height: 15px;
+	}
+	.slot-highlight {
+		margin: 3px 0;
+		border: 1px solid #7b939c;
+		min-height: 40px;
+	}
+
+	.portlets {
+		min-height: 15px;
+
+		&-hover {
+			background-color: silver;
+		}
+		&-active {
+			background-color: silver;
+		}
+	}
+	.portlet-highlight {
+		margin: 0;
+		border: 1px solid #7b939c;
+		min-height: 40px;
+	}
+
+	&.container {
+		.col-12 {
+			float: left;
+			width: 100%!important;
+		}
+		.col-11 {
+			float: left;
+			width: 91.66666667%!important;
+		}
+		.col-10 {
+			float: left;
+			width: 83.33333333%!important;
+		}
+		.col-9 {
+			float: left;
+			width: 75%!important;
+		}
+		.col-8 {
+			float: left;
+			width: 66.66666667%!important;
+		}
+		.col-7 {
+			float: left;
+			width: 58.33333333%!important;
+		}
+		.col-6 {
+			float: left;
+			width: 50%!important;
+		}
+		.col-5 {
+			float: left;
+			width: 41.66666667%!important;
+		}
+		.col-4 {
+			float: left;
+			width: 33.33333333%!important;
+		}
+		.col-3 {
+			float: left;
+			width: 25%!important;
+		}
+		.col-2 {
+			float: left;
+			width: 16.66666667%!important;
+		}
+		.col-1 {
+			float: left;
+			width: 8.33333333%!important;
+		}
+		.col-0 {
+			float: left;
+			width: 100%!important;
+			opacity: 0.5;
+
+			>.portlets {
+				display: none;
+			}
+		}
+	}
+	&.container-xs {
+		max-width: 750px!important;
+	}
+	&.container-sm {
+		width: 750px!important;
+	}
+	&.container-md {
+		width: 970px!important;
+	}
+	&.container-lg {
+		width: 1170px!important;
+	}
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/site.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,48 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortalTemplateContainer
+from pyams_utils.interfaces.site import ISiteGenerations
+from zope.site.interfaces import INewLocalSite
+
+# import packages
+from pyams_portal.template import PortalTemplateContainer
+from pyams_utils.registry import utility_config
+from pyams_utils.site import check_required_utilities
+from pyramid.events import subscriber
+
+
+REQUIRED_UTILITIES = ((IPortalTemplateContainer, '', PortalTemplateContainer, 'Portal templates'), )
+
+
+@subscriber(INewLocalSite)
+def handle_new_local_site(event):
+    """Create a new templates container when a site is created"""
+    site = event.manager.__parent__
+    check_required_utilities(site, REQUIRED_UTILITIES)
+
+
+@utility_config(name='PyAMS portal', provides=ISiteGenerations)
+class PortalGenerationsChecker(object):
+    """Portal generations checker"""
+
+    generation = 1
+
+    def evolve(self, site, current=None):
+        """Check for required utilities"""
+        check_required_utilities(site, REQUIRED_UTILITIES)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/slot.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,159 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import ISlotConfiguration, IPortalTemplateConfiguration, IPortalTemplate, IPortalPage
+
+# import packages
+from persistent import Persistent
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+
+
+PORTAL_SLOTS_KEY = 'pyams_portal.slots'
+
+
+@implementer(ISlotConfiguration)
+class SlotConfiguration(Persistent, Contained):
+    """Portal slot class"""
+
+    slot_name = FieldProperty(ISlotConfiguration['slot_name'])
+    visible = FieldProperty(ISlotConfiguration['visible'])
+    _inherit_parent = FieldProperty(ISlotConfiguration['inherit_parent'])
+    _xs_width = FieldProperty(ISlotConfiguration['xs_width'])
+    _sm_width = FieldProperty(ISlotConfiguration['sm_width'])
+    _md_width = FieldProperty(ISlotConfiguration['md_width'])
+    _lg_width = FieldProperty(ISlotConfiguration['lg_width'])
+    _css_class = FieldProperty(ISlotConfiguration['css_class'])
+
+    def __init__(self, slot_name, **kwargs):
+        self.slot_name = slot_name
+        self.xs_width = 12
+        self.sm_width = 12
+        self.md_width = 12
+        self.lg_width = 12
+        for key, value in kwargs.items():
+            setattr(self, key, value)
+
+    @property
+    def template(self):
+        if IPortalTemplate.providedBy(self.__parent__):
+            return self.__parent__
+        else:
+            return IPortalPage(self.__parent__).template
+
+    @property
+    def can_inherit(self):
+        return IPortalPage.providedBy(self.__parent__)
+
+    @property
+    def inherit_parent(self):
+        return self._inherit_parent if self.can_inherit else False
+
+    @inherit_parent.setter
+    def inherit_parent(self, value):
+        self._inherit_parent = value
+
+    @property
+    def xs_width(self):
+        if self.inherit_parent:
+            config = IPortalTemplateConfiguration(self.template)
+            return config.get_slot_configuration(self.slot_name).xs_width
+        else:
+            return self._xs_width
+        
+    @xs_width.setter
+    def xs_width(self, value):
+        self._xs_width = value
+
+    @property
+    def sm_width(self):
+        if self.inherit_parent:
+            config = IPortalTemplateConfiguration(self.template)
+            return config.get_slot_configuration(self.slot_name).sm_width
+        else:
+            return self._sm_width
+        
+    @sm_width.setter
+    def sm_width(self, value):
+        self._sm_width = value
+
+    @property
+    def md_width(self):
+        if self.inherit_parent:
+            config = IPortalTemplateConfiguration(self.template)
+            return config.get_slot_configuration(self.slot_name).md_width
+        else:
+            return self._md_width
+        
+    @md_width.setter
+    def md_width(self, value):
+        self._md_width = value
+
+    @property
+    def lg_width(self):
+        if self.inherit_parent:
+            config = IPortalTemplateConfiguration(self.template)
+            return config.get_slot_configuration(self.slot_name).lg_width
+        else:
+            return self._lg_width
+        
+    @lg_width.setter
+    def lg_width(self, value):
+        self._lg_width = value
+
+    @property
+    def css_class(self):
+        if self.inherit_parent:
+            config = IPortalTemplateConfiguration(self.template)
+            return config.get_slot_configuration(self.slot_name).css_class
+        else:
+            return self._css_class
+
+    @css_class.setter
+    def css_class(self, value):
+        self._css_class = value
+
+    def get_css_class(self, device=None):
+        if not device:
+            device = ('xs', 'sm', 'md', 'lg')
+        elif isinstance(device, str):
+            device = (device, )
+        result = [self.css_class or '']
+        for attr in device:
+            width = getattr(self, attr + '_width')
+            result.append('col-{0}-{1}'.format(attr, width))
+        return ' '.join(result)
+
+    def get_width(self, device=None):
+        if not device:
+            device = ('xs', 'sm', 'md', 'lg')
+        elif isinstance(device, str):
+            device = (device, )
+        result = {}
+        for attr in device:
+            result[attr] = getattr(self, attr + '_width')
+        return result
+
+    def set_width(self, width, device=None):
+        if not device:
+            device = ('xs', 'sm', 'md', 'lg')
+        elif isinstance(device, str):
+            device = (device, )
+        for attr in device:
+            setattr(self, attr + '_width', width)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/template.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,441 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortalTemplate, IPortalTemplateConfiguration, IPortalContext, IPortalPage, \
+    IPortletConfiguration, IPortlet, IPortalTemplateContainer, IPortalWfTemplate, IPortalTemplateContainerConfiguration
+from pyams_workflow.interfaces import IWorkflowVersions
+from zope.annotation.interfaces import IAnnotations
+from zope.lifecycleevent.interfaces import IObjectAddedEvent, IObjectRemovedEvent
+from zope.traversing.interfaces import ITraversable
+
+# import packages
+from persistent import Persistent
+from persistent.list import PersistentList
+from persistent.mapping import PersistentMapping
+from pyams_portal.slot import SlotConfiguration
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.registry import get_local_registry
+from pyams_utils.request import check_request
+from pyramid.events import subscriber
+from pyramid.threadlocal import get_current_registry
+from zope.componentvocabulary.vocabulary import UtilityVocabulary
+from zope.container.contained import Contained
+from zope.container.folder import Folder
+from zope.copy import clone
+from zope.interface import implementer
+from zope.lifecycleevent import ObjectCreatedEvent
+from zope.location.location import locate
+from zope.schema.fieldproperty import FieldProperty
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm, getVocabularyRegistry
+
+
+@implementer(IPortalTemplateContainer)
+class PortalTemplateContainer(Folder):
+    """Portal template container"""
+
+
+@implementer(IPortalTemplateContainerConfiguration)
+class PortalTemplateContainerConfiguration(Persistent, Contained):
+    """Portal template container configuration"""
+
+    selected_portlets = FieldProperty(IPortalTemplateContainerConfiguration['selected_portlets'])
+
+
+PORTAL_TEMPLATE_CONTAINER_CONFIGURATION_KEY = 'pyams_portal.container.configuration'
+
+
+@adapter_config(context=IPortalTemplateContainer, provides=IPortalTemplateContainerConfiguration)
+def PortalTemplateContainerConfigurationFactory(context):
+    """Portal template container configuration factory"""
+    annotations = IAnnotations(context)
+    config = annotations.get(PORTAL_TEMPLATE_CONTAINER_CONFIGURATION_KEY)
+    if config is None:
+        config = annotations[PORTAL_TEMPLATE_CONTAINER_CONFIGURATION_KEY] = PortalTemplateContainerConfiguration()
+        get_current_registry().notify(ObjectCreatedEvent(config))
+        locate(config, context)
+    return config
+
+
+@implementer(IPortalTemplate)
+class PortalTemplate(Persistent, Contained):
+    """Portal template persistent class"""
+
+    name = FieldProperty(IPortalTemplate['name'])
+
+
+@implementer(IPortalWfTemplate)
+class PortalWfTemplate(Persistent, Contained):
+    """Portal template workflow manager class"""
+
+    content_class = PortalTemplate
+    workflow_name = 'PyAMS portal template workflow'
+    view_permission = None
+
+
+@subscriber(IObjectAddedEvent, context_selector=IPortalWfTemplate)
+def handle_added_template(event):
+    """Register shared template"""
+    registry = get_local_registry()
+    if (registry is not None) and IPortalTemplateContainer.providedBy(event.newParent):
+        registry.registerUtility(event.object, IPortalWfTemplate, name=event.object.__name__)
+
+
+@subscriber(IObjectRemovedEvent, context_selector=IPortalWfTemplate)
+def handle_removed_template(event):
+    """Unregister removed template"""
+    registry = get_local_registry()
+    if (registry is not None) and IPortalTemplateContainer.providedBy(event.oldParent):
+        registry.unregisterUtility(event.object, IPortalWfTemplate, name=event.object.__name__)
+
+
+class PortalTemplatesVocabulary(UtilityVocabulary):
+    """Portal templates vocabulary"""
+
+    interface = IPortalWfTemplate
+    nameOnly = True
+
+getVocabularyRegistry().register('PyAMS portal templates', PortalTemplatesVocabulary)
+
+
+@implementer(IPortalTemplateConfiguration)
+class PortalTemplateConfiguration(Persistent, Contained):
+    """Portal template configuration"""
+
+    rows = FieldProperty(IPortalTemplateConfiguration['rows'])
+    _slot_names = FieldProperty(IPortalTemplateConfiguration['slot_names'])
+    _slot_order = FieldProperty(IPortalTemplateConfiguration['slot_order'])
+    _slots = FieldProperty(IPortalTemplateConfiguration['slots'])
+    slot_config = FieldProperty(IPortalTemplateConfiguration['slot_config'])
+    portlet_config = FieldProperty(IPortalTemplateConfiguration['portlet_config'])
+
+    def __init__(self):
+        self._slot_names = PersistentList()
+        self._slot_order = PersistentMapping()
+        self._slot_order[0] = PersistentList()
+        self._slots = PersistentMapping()
+        self._slots[0] = PersistentMapping()
+        self.slot_config = PersistentMapping()
+        self.portlet_config = PersistentMapping()
+
+    def add_row(self):
+        """Add new row and return last row index (0 based)"""
+        self.rows += 1
+        last_index = self.rows - 1
+        self.slot_order[last_index] = PersistentList()
+        self.slots[last_index] = PersistentMapping()
+        return last_index
+
+    def set_row_order(self, order):
+        """Change template row order"""
+        if not isinstance(order, (list, tuple)):
+            order = list(order)
+        old_slot_order = self.slot_order
+        old_slots = self.slots
+        assert len(order) == self.rows
+        new_slot_order = PersistentMapping()
+        new_slots = PersistentMapping()
+        for index, row_id in enumerate(order):
+            new_slot_order[index] = old_slot_order.get(row_id) or PersistentList()
+            new_slots[index] = old_slots.get(row_id) or PersistentMapping()
+        if self.slot_order != new_slot_order:
+            self.slot_order = new_slot_order
+            self.slots = new_slots
+
+    def delete_row(self, row_id):
+        """Delete template row"""
+        assert row_id in self.slots
+        for slot_name in self.slots.get(row_id, {}).keys():
+            if slot_name in self.slot_names:
+                self.slot_names.remove(slot_name)
+            if slot_name in self.slot_config:
+                del self.slot_config[slot_name]
+            if slot_name in self.portlet_config:
+                del self.portlet_config[slot_name]
+        for index in range(row_id, self.rows-1):
+            self.slot_order[index] = self.slot_order[index+1]
+            self.slots[index] = self.slots[index+1]
+        if self.rows > 0:
+            del self.slot_order[self.rows-1]
+            del self.slots[self.rows-1]
+        self.rows -= 1
+
+    @property
+    def slot_names(self):
+        if IPortalTemplate.providedBy(self.__parent__):
+            return self._slot_names
+        else:
+            return IPortalTemplateConfiguration(self.__parent__).slot_names
+
+    @slot_names.setter
+    def slot_names(self, value):
+        self._slot_names = value
+
+    @property
+    def slot_order(self):
+        if IPortalTemplate.providedBy(self.__parent__):
+            return self._slot_order
+        else:
+            return IPortalTemplateConfiguration(self.__parent__).slot_order
+
+    @slot_order.setter
+    def slot_order(self, value):
+        self._slot_order = value
+
+    @property
+    def slots(self):
+        if IPortalTemplate.providedBy(self.__parent__):
+            return self._slots
+        else:
+            return IPortalTemplateConfiguration(self.__parent__).slots
+
+    @slots.setter
+    def slots(self, value):
+        self._slots = value
+
+    def add_slot(self, slot_name, row_id=None):
+        assert slot_name not in self.slot_names
+        self.slot_names.append(slot_name)
+        if row_id is None:
+            row_id = 0
+        # init slots order
+        if row_id not in self.slot_order:
+            self.slot_order[row_id] = PersistentList()
+        self.slot_order[row_id].append(slot_name)
+        # init slots portlets
+        if row_id not in self.slots:
+            self.slots[row_id] = PersistentMapping()
+        self.slots[row_id][slot_name] = PersistentList()
+        # init slots configuration
+        slot = self.slot_config[slot_name] = SlotConfiguration(slot_name)
+        locate(slot, self.__parent__)
+        return row_id, slot_name
+
+    def set_slot_order(self, order):
+        """Set slots order"""
+        old_slot_order = self.slot_order
+        old_slots = self.slots
+        new_slot_order = PersistentMapping()
+        new_slots = PersistentMapping()
+        for row_id in sorted(map(int, order.keys())):
+            new_slot_order[row_id] = PersistentList(order[row_id])
+            new_slots[row_id] = PersistentMapping()
+            for slot_name in order[row_id]:
+                old_row_id = self.get_slot_row(slot_name)
+                new_slots[row_id][slot_name] = old_slots[old_row_id][slot_name]
+        if new_slot_order != old_slot_order:
+            self.slot_order = new_slot_order
+            self.slots = new_slots
+
+    def get_slot_row(self, slot_name):
+        for row_id in self.slot_order:
+            if slot_name in self.slot_order[row_id]:
+                return row_id
+
+    def get_slots(self, row_id):
+        """Get ordered slots list"""
+        return self.slot_order.get(row_id, [])
+
+    def get_slots_width(self, device=None):
+        """Get slots width"""
+        result = {}
+        for slot_name, config in self.slot_config.items():
+            result[slot_name] = config.get_width(device)
+        return result
+
+    def set_slot_width(self, slot_name, device, width):
+        """Set slot width"""
+        self.slot_config[slot_name].set_width(width, device)
+
+    def get_slot_configuration(self, slot_name):
+        """Get slot configuration"""
+        if slot_name not in self.slot_names:
+            return None
+        config = self.slot_config.get(slot_name)
+        if config is None:
+            if IPortalTemplate.providedBy(self.__parent__):
+                config = SlotConfiguration()
+            else:
+                config = clone(IPortalTemplateConfiguration(self.__parent__).get_slot_configuration(slot_name))
+                config.inherit_parent = True
+            self.slot_config[slot_name] = config
+            locate(config, self.__parent__)
+        return config
+
+    def delete_slot(self, slot_name):
+        """Delete slot and associated portlets"""
+        assert slot_name in self.slot_names
+        row_id = self.get_slot_row(slot_name)
+        del self.portlet_config[slot_name]
+        del self.slot_config[slot_name]
+        del self.slots[row_id][slot_name]
+        self.slot_order[row_id].remove(slot_name)
+        self.slot_names.remove(slot_name)
+
+    def add_portlet(self, portlet_name, slot_name):
+        """Add portlet to given slot"""
+        assert slot_name in self.slot_names
+        row_id = self.get_slot_row(slot_name)
+        if slot_name not in self.slots.get(row_id):
+            self.slots[row_id][slot_name] = PersistentList()
+        self.slots[row_id][slot_name].append(portlet_name)
+        if slot_name not in self.portlet_config:
+            self.portlet_config[slot_name] = PersistentMapping()
+        position = len(self.slots[row_id][slot_name]) - 1
+        portlet = get_current_registry().getUtility(IPortlet, name=portlet_name)
+        config = IPortletConfiguration(portlet)
+        config.slot_name = slot_name
+        config.position = position
+        locate(config, self.__parent__, '++portlet++{0}::{1}'.format(slot_name, position))
+        self.portlet_config[slot_name][position] = config
+        return {'portlet_name': portlet_name,
+                'slot_name': slot_name,
+                'position': position,
+                'label': check_request().localizer.translate(portlet.label)}
+
+    def set_portlet_order(self, order):
+        """Set portlet order"""
+        source = order['from']
+        source_slot = source['slot']
+        source_row = self.get_slot_row(source_slot)
+        target = order['to']
+        target_slot = target['slot']
+        target_row = self.get_slot_row(target_slot)
+        portlet_config = self.portlet_config
+        old_config = portlet_config[source_slot].pop(source['position'])
+        target_config = PersistentMapping()
+        for index, (slot_name, portlet_name, position) in enumerate(zip(target['slots'], target['names'],
+                                                                        target['positions'])):
+            if (slot_name == source_slot) and (position == source['position']):
+                target_config[index] = old_config
+            else:
+                target_config[index] = portlet_config[slot_name][position]
+            target_config[index].slot_name = target_slot
+            target_config[index].position = index
+            locate(target_config[index], self.__parent__, '++portlet++{0}::{1}'.format(slot_name, index))
+        portlet_config[target_slot] = target_config
+        # re-order source portlets
+        config = portlet_config[source_slot]
+        for index, key in enumerate(sorted(config)):
+            if index != key:
+                config[index] = config.pop(key)
+                config[index].position = index
+                locate(config[index], self.__parent__, '++portlet++{0}::{1}'.format(source_slot, index))
+        # re-order target portlets
+        if target_slot != source_slot:
+            config = portlet_config[target_slot]
+            for index, key in enumerate(sorted(config)):
+                config[index] = config.pop(key)
+                config[index].position = index
+                locate(config[index], self.__parent__, '++portlet++{0}::{1}'.format(target_slot, index))
+        self.portlet_config = portlet_config
+        del self.slots[source_row][source_slot][source['position']]
+        self.slots[target_row][target_slot] = PersistentList(target['names'])
+
+    def get_portlet_configuration(self, slot_name, position):
+        """Get portlet configuration"""
+        if slot_name not in self.slot_names:
+            return None
+        config = self.portlet_config.get(slot_name, {}).get(position)
+        if config is None:
+            if IPortalTemplate.providedBy(self.__parent__):
+                portlet_name = self.slots[slot_name][position]
+                portlet = get_current_registry().queryUtility(IPortlet, name=portlet_name)
+                config = IPortletConfiguration(portlet)
+            else:
+                config = clone(IPortalTemplateConfiguration(self.__parent__).get_portlet_configuration(slot_name,
+                                                                                                       position))
+                config.inherit_parent = True
+            self.portlet_config[slot_name][position] = config
+            locate(config, self.__parent__)
+        return config
+
+    def delete_portlet(self, slot_name, position):
+        """Delete portlet"""
+        assert slot_name in self.slot_names
+        row_id = self.get_slot_row(slot_name)
+        config = self.portlet_config[slot_name]
+        del config[position]
+        if len(config) and (position < max(tuple(config.keys()))):
+            for index, key in enumerate(sorted(config)):
+                config[index] = config.pop(key)
+                config[index].position = index
+                locate(config[index], self.__parent__, '++portlet++{0}::{1}'.format(slot_name, index))
+        del self.slots[row_id][slot_name][position]
+
+
+class PortalTemplateSlotsVocabulary(SimpleVocabulary):
+    """Portal template slots vocabulary"""
+
+    def __init__(self, context):
+        config = IPortalTemplateConfiguration(context)
+        terms = [SimpleTerm(slot_name) for slot_name in sorted(config.slot_names)]
+        super(PortalTemplateSlotsVocabulary, self).__init__(terms)
+
+getVocabularyRegistry().register('PyAMS template slots', PortalTemplateSlotsVocabulary)
+
+
+@adapter_config(name='portlet', context=IPortalTemplate, provides=ITraversable)
+class PortalTemplatePortletTraverser(ContextAdapter):
+    """++portlet++ namespace traverser"""
+
+    def traverse(self, name, furtherpath=None):
+        config = IPortalTemplateConfiguration(self.context)
+        if name:
+            slot_name, position = name.split('::')
+            return config.get_portlet_configuration(slot_name, int(position))
+        else:
+            return config
+
+
+TEMPLATE_CONFIGURATION_KEY = 'pyams_portal.template'
+
+
+@adapter_config(context=IPortalTemplate, provides=IPortalTemplateConfiguration)
+def PortalTemplateConfigurationFactory(context):
+    """Portal template configuration factory"""
+    annotations = IAnnotations(context)
+    config = annotations.get(TEMPLATE_CONFIGURATION_KEY)
+    if config is None:
+        config = annotations[TEMPLATE_CONFIGURATION_KEY] = PortalTemplateConfiguration()
+        get_current_registry().notify(ObjectCreatedEvent(config))
+        locate(config, context)
+    return config
+
+
+@adapter_config(context=IPortalContext, provides=IPortalTemplateConfiguration)
+def PortalContextConfigurationFactory(context):
+    """Portal context configuration factory"""
+    page = IPortalPage(context)
+    if page.use_local_template:
+        template = IWorkflowVersions(page.template).get_last_versions()[0]
+        config = IPortalTemplateConfiguration(template)
+    else:
+        annotations = IAnnotations(context)
+        config = annotations.get(TEMPLATE_CONFIGURATION_KEY)
+        if config is None:
+            # we clone template configuration
+            config = annotations[TEMPLATE_CONFIGURATION_KEY] = clone(IPortalTemplateConfiguration(page.template))
+            get_current_registry().notify(ObjectCreatedEvent(config))
+            locate(config, context)
+    return config
+
+
+@adapter_config(context=IPortletConfiguration, provides=IPortalTemplateConfiguration)
+def PortalPortletConfigurationFactory(context):
+    """Portal portlet configuration factory"""
+    return IPortalTemplateConfiguration(context.__parent__)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/tests/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/tests/test_utilsdocs.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,59 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+Generic Test case for pyams_portal doctest
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.dirname(__file__)
+
+def doc_suite(test_dir, setUp=None, tearDown=None, globs=None):
+    """Returns a test suite, based on doctests found in /doctest."""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    doctest_dir = os.path.join(package_dir, 'doctests')
+
+    # filtering files on extension
+    docs = [os.path.join(doctest_dir, doc) for doc in
+            os.listdir(doctest_dir) if doc.endswith('.txt')]
+
+    for test in docs:
+        suite.append(doctest.DocFileSuite(test, optionflags=flags,
+                                          globs=globs, setUp=setUp,
+                                          tearDown=tearDown,
+                                          module_relative=False))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/tests/test_utilsdocstrings.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,62 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+"""
+Generic Test case for pyams_portal doc strings
+"""
+__docformat__ = 'restructuredtext'
+
+import unittest
+import doctest
+import sys
+import os
+
+
+current_dir = os.path.abspath(os.path.dirname(__file__))
+
+def doc_suite(test_dir, globs=None):
+    """Returns a test suite, based on doc tests strings found in /*.py"""
+    suite = []
+    if globs is None:
+        globs = globals()
+
+    flags = (doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE |
+             doctest.REPORT_ONLY_FIRST_FAILURE)
+
+    package_dir = os.path.split(test_dir)[0]
+    if package_dir not in sys.path:
+        sys.path.append(package_dir)
+
+    # filtering files on extension
+    docs = [doc for doc in
+            os.listdir(package_dir) if doc.endswith('.py')]
+    docs = [doc for doc in docs if not doc.startswith('__')]
+
+    for test in docs:
+        fd = open(os.path.join(package_dir, test))
+        content = fd.read()
+        fd.close()
+        if '>>> ' not in content:
+            continue
+        test = test.replace('.py', '')
+        location = 'pyams_portal.%s' % test
+        suite.append(doctest.DocTestSuite(location, optionflags=flags,
+                                          globs=globs))
+
+    return unittest.TestSuite(suite)
+
+def test_suite():
+    """returns the test suite"""
+    return doc_suite(current_dir)
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='test_suite')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/workflow.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,223 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+from datetime import datetime
+
+# import interfaces
+from pyams_workflow.interfaces import IWorkflowPublicationInfo, IWorkflowState, IWorkflowVersions, IWorkflowInfo, \
+    ObjectClonedEvent, IWorkflow
+
+# import packages
+from pyams_utils.registry import utility_config
+from pyams_workflow.workflow import Transition, Workflow
+from pyramid.threadlocal import get_current_registry
+from zope.copy import copy
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
+
+from pyams_portal import _
+
+
+DRAFT = 'draft'  # new template
+PUBLISHED = 'published'  # published template
+RETIRED = 'retired'  # retired template
+ARCHIVED = 'archived'  # archive done
+DELETED = 'deleted'  # deleted template
+
+
+STATUS_IDS = ('draft', 'published', 'retired', 'archived', 'deleted')
+STATUS_LABELS = (_("Draft"),
+                 _("Published"),
+                 _("Retired"),
+                 _("Archived"),
+                 _("Deleted"))
+
+STATUS_VOCABULARY = SimpleVocabulary([SimpleTerm(STATUS_IDS[i], STATUS_IDS[i], t)
+                                      for i, t in enumerate(STATUS_LABELS)])
+
+
+def publish_action(wf, context):
+    """Publish template"""
+    now = datetime.utcnow()
+    wf_content = IWorkflowPublicationInfo(context)
+    if wf_content.first_publication_date is None:
+        wf_content.first_publication_date = now
+    IWorkflowPublicationInfo(context).publication_date = now
+    version_id = IWorkflowState(context).version_id
+    for version in IWorkflowVersions(context).get_versions(('published', 'retired')):
+        if version is not context:
+            IWorkflowInfo(version).fire_transition_toward('archived',
+                                                          comment="Published version {0}".format(version_id))
+
+
+def clone_action(wf, context):
+    """Duplicate template"""
+    result = copy(context)
+    registry = get_current_registry()
+    registry.notify(ObjectClonedEvent(result, context))
+    return result
+
+
+def retire_action(wf, context):
+    """Archive template"""
+    now = datetime.utcnow()
+    IWorkflowPublicationInfo(context).publication_expiration_date = now
+
+
+def archive_action(wf, context):
+    """Archive template"""
+    now = datetime.utcnow()
+    content = IWorkflowPublicationInfo(context)
+    content.publication_expiration_date = min(content.publication_expiration_date or now, now)
+
+
+def can_delete(wf, context):
+    content = IWorkflowPublicationInfo(context)
+    return content.publication_effective_date is None
+
+
+def delete_action(wf, context):
+    """Delete draft version"""
+    parent = context.__parent__
+    name = context.__name__
+    del parent[name]
+
+
+#
+# Workflow transitions
+#
+
+init = Transition(transition_id='init',
+                  title=_("Initialize"),
+                  source=None,
+                  destination=DRAFT)
+
+draft_to_published = Transition('draft_to_published',
+                                title=_("Publish..."),
+                                source=DRAFT,
+                                destination=PUBLISHED,
+                                permission='portal.templates.manage',
+                                action=publish_action,
+                                order=1,
+                                menu_css_class='fa fa-fw fa-play',
+                                view_name='wf-publish.html',
+                                html_help=_('''This content is currently in DRAFT mode.
+                                               Publishing it will make it publicly visible.'''))
+
+published_to_retired = Transition('published_to_retired',
+                                  title=_("Retire..."),
+                                  source=PUBLISHED,
+                                  destination=RETIRED,
+                                  permission='portal.templates.manage',
+                                  action=retire_action,
+                                  order=2,
+                                  menu_css_class='fa fa-fw fa-stop',
+                                  view_name='wf-retire.html',
+                                  html_help=_('''This content is actually published.
+                                                 You can retire it to make it invisible, but contents using this
+                                                 template won't be visible anymore!'''))
+
+published_to_draft = Transition('published_to_draft',
+                                title=_("Create new version..."),
+                                source=PUBLISHED,
+                                destination=DRAFT,
+                                permission='portal.templates.manage',
+                                action=clone_action,
+                                order=99,
+                                menu_css_class='fa fa-fw fa-copy',
+                                view_name='wf-clone.html')
+
+retired_to_published = Transition('retired_to_published',
+                                  title=_("Re-publish..."),
+                                  source=RETIRED,
+                                  destination=PUBLISHED,
+                                  permission='portal.templates.manage',
+                                  action=publish_action,
+                                  order=1,
+                                  menu_css_class='fa fa-fw fa-play',
+                                  view_name='wf-publish.html',
+                                  html_help=_('''This content was published and retired.
+                                                 You can re-publish it to make it visible again.'''))
+
+published_to_archived = Transition('published_to_archived',
+                                   title=_("Archive..."),
+                                   source=PUBLISHED,
+                                   destination=ARCHIVED,
+                                   permission='portal.templates.manage',
+                                   action=archive_action,
+                                   order=3,
+                                   menu_css_class='fa fa-fw fa-archive',
+                                   view_name='wf-archive.html',
+                                   html_help=_('''This content is currently published.
+                                                  If it is archived, it will not be possible to make it visible again
+                                                  except by creating a new version!'''))
+
+retired_to_archived = Transition('retired_to_archived',
+                                 title=_("Archive..."),
+                                 source=RETIRED,
+                                 destination=ARCHIVED,
+                                 permission='portal.templates.manage',
+                                 action=archive_action,
+                                 order=3,
+                                 menu_css_class='fa fa-fw fa-archive',
+                                 view_name='wf-archive.html',
+                                 html_help=_('''This content has been published but is currently retired.
+                                                If it is archived, it will not be possible to make it visible again
+                                                except by creating a new version!'''))
+
+archived_to_draft = Transition('archived_to_draft',
+                               title=_("Create new version..."),
+                               source=ARCHIVED,
+                               destination=DRAFT,
+                               permission='portal.templates.manage',
+                               action=clone_action,
+                               order=99,
+                               menu_css_class='fa fa-fw fa-copy',
+                               view_name='wf-clone.html')
+
+deleted = Transition('delete',
+                     title=_("Delete..."),
+                     source=DRAFT,
+                     destination=DELETED,
+                     condition=can_delete,
+                     action=delete_action,
+                     order=6,
+                     menu_css_class='fa fa-fw fa-trash',
+                     view_name='wf-delete.html',
+                     html_help=_('''This content has never been published.
+                                    It can be removed and definitely deleted.'''))
+
+wf_transitions = [init,
+                  draft_to_published,
+                  published_to_retired,
+                  published_to_draft,
+                  retired_to_published,
+                  published_to_archived,
+                  retired_to_archived,
+                  archived_to_draft,
+                  deleted]
+
+
+wf = Workflow(wf_transitions,
+              states=STATUS_VOCABULARY,
+              published_states=(PUBLISHED,))
+
+
+@utility_config(name='PyAMS portal template workflow', provides=IWorkflow)
+class WorkflowUtility(object):
+    """Workflow utility registration"""
+
+    def __new__(cls):
+        return wf
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/container.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,204 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortalTemplateContainer, IPortalWfTemplate, IPortalTemplateContainerConfiguration
+from pyams_portal.zmi.interfaces import IPortalTemplateContainerMenu
+from pyams_skin.interfaces import IInnerPage, IPageHeader
+from pyams_skin.interfaces.container import ITable, ITableElementEditor
+from pyams_skin.layer import IPyAMSLayer
+from pyams_workflow.interfaces import IWorkflowVersions
+from pyams_zmi.interfaces.menu import IControlPanelMenu
+from pyams_zmi.layer import IAdminLayer
+from z3c.table.interfaces import IColumn, IValues
+from zope.component.interfaces import ISite
+
+# import packages
+from pyams_form.form import AJAXEditForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.container import ContainerView
+from pyams_skin.page import DefaultPageHeaderAdapter
+from pyams_skin.table import DefaultElementEditorAdapter, BaseTable, TrashColumn
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
+from pyams_utils.registry import query_utility
+from pyams_utils.traversing import get_parent
+from pyams_utils.url import absolute_url
+from pyams_viewlet.manager import viewletmanager_config
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_workflow.zmi.workflow import WorkflowContentNameColumn
+from pyams_zmi.form import AdminDialogEditForm
+from pyams_zmi.view import AdminView
+from pyramid.url import resource_url
+from pyramid.view import view_config
+from z3c.form import field
+from zope.interface import implementer, Interface
+
+from pyams_portal import _
+
+
+#
+# Template container views
+#
+
+@adapter_config(context=(IPortalTemplateContainer, IAdminLayer, ITable), provides=ITableElementEditor)
+class PortalTemplateContainerTableElementEditor(DefaultElementEditorAdapter):
+    """Portal template container table element editor"""
+
+    view_name = 'portal-templates.html'
+    modal_target = False
+
+    @property
+    def url(self):
+        site = get_parent(self.context, ISite)
+        return resource_url(site, self.request, 'admin.html#{0}'.format(self.view_name))
+
+
+@viewlet_config(name='portal-templates.menu', context=ISite, layer=IAdminLayer, manager=IControlPanelMenu,
+                permission='system.view', weight=20)
+@viewletmanager_config(name='portal-templates.menu', context=ISite, layer=IAdminLayer)
+@implementer(IPortalTemplateContainerMenu)
+class PortalTemplateContainerMenuItem(MenuItem):
+    """Portal template container menu"""
+
+    label = _("Portal templates")
+    icon_class = 'fa fa-fw fa-columns'
+    url = '#portal-templates.html'
+
+
+class PortalTemplateContainerTable(BaseTable):
+    """Portal template container table"""
+
+    id = 'portal_templates_table'
+    title = _("Shared portal templates")
+
+    @property
+    def data_attributes(self):
+        manager = query_utility(IPortalTemplateContainer)
+        attributes = super(PortalTemplateContainerTable, self).data_attributes
+        table_attrs = {'data-ams-location': absolute_url(manager, self.request),
+                       'data-ams-plugins': 'pyams_workflow pyams_portal',
+                       'data-ams-plugin-pyams_workflow-src': "/--static--/pyams_workflow/js/workflow{MyAMS.devext}.js",
+                       'data-ams-plugin-pyams_portal-src': "/--static--/pyams_portal/js/portal{MyAMS.devext}.js",
+                       'data-ams-plugin-pyams_portal-css': "/--static--/pyams_portal/css/portal{MyAMS.devext}.css"}
+        if 'table' in attributes:
+            attributes['table'].update(table_attrs)
+        else:
+            attributes['table'] = table_attrs
+        return attributes
+
+
+@adapter_config(context=(IPortalWfTemplate, IAdminLayer, PortalTemplateContainerTable), provides=ITableElementEditor)
+class PortalTemplateTableElementEditor(DefaultElementEditorAdapter):
+    """Portal template table element editor"""
+
+    modal_target = False
+
+    @property
+    def url(self):
+        wf_versions = IWorkflowVersions(self.context).get_last_versions(count=1)
+        if wf_versions:
+            return resource_url(wf_versions[0], self.request, 'admin.html#{0}'.format(self.view_name))
+        else:
+            return None
+
+
+@adapter_config(name='name', context=(Interface, IAdminLayer, PortalTemplateContainerTable), provides=IColumn)
+class PortalTemplateContainerNameColumn(WorkflowContentNameColumn):
+    """Portal template container name column"""
+
+    name_field = 'name'
+
+
+@adapter_config(name='trash', context=(Interface, IAdminLayer, PortalTemplateContainerTable), provides=IColumn)
+class PortalTemplateContainerTrashColumn(TrashColumn):
+    """Portal template container trash column"""
+
+    icon_hint = _("Delete template")
+    permission = 'portal.templates.manage'
+
+
+@adapter_config(context=(ISite, IAdminLayer, PortalTemplateContainerTable), provides=IValues)
+class PortalTemplateContainerValuesAdapter(ContextRequestViewAdapter):
+    """Portal template container values adapter"""
+
+    @property
+    def values(self):
+        manager = query_utility(IPortalTemplateContainer)
+        if manager is not None:
+            return manager.values()
+        return ()
+
+
+@pagelet_config(name='portal-templates.html', context=ISite, layer=IPyAMSLayer, permission='system.view')
+@implementer(IInnerPage)
+class PortalTemplateContainerView(AdminView, ContainerView):
+    """Portal template container view"""
+
+    table_class = PortalTemplateContainerTable
+
+    def __init__(self, context, request):
+        super(PortalTemplateContainerView, self).__init__(context, request)
+
+
+@adapter_config(context=(ISite, IAdminLayer, PortalTemplateContainerView), provides=IPageHeader)
+class PortalTemplateContainerHeaderAdapter(DefaultPageHeaderAdapter):
+    """Portal template container header adapter"""
+
+    icon_class = 'fa fa-fw fa-columns'
+    title = _("Portal")
+    subtitle = _("Portal templates")
+
+
+#
+# Templates container configuration views
+#
+
+@viewlet_config(name='templates-container-configuration.menu', context=ISite, layer=IAdminLayer,
+                manager=IPortalTemplateContainerMenu, permission='system.view', weight=1)
+class PortalTemplatesContainerPropertiesMenu(MenuItem):
+    """Portal template container configuration menu"""
+
+    label = _("Selected portlets...")
+    icon_class = 'fa-thumb-tack'
+
+    url = 'properties.html'
+    modal_target = True
+
+    def get_url(self):
+        container = query_utility(IPortalTemplateContainer)
+        return absolute_url(container, self.request, self.url)
+
+
+@pagelet_config(name='properties.html', context=IPortalTemplateContainer, layer=IPyAMSLayer,
+                permission='system.view')
+class PortalTemplateContainerPropertiesEditForm(AdminDialogEditForm):
+    """Portal template container properties edit form"""
+
+    title = _("Portal templates container")
+    legend = _("Edit selected portlets")
+    icon_css_class = 'fa fa-fw fa-thumb-tack'
+
+    fields = field.Fields(IPortalTemplateContainerConfiguration)
+    ajax_handler = 'properties.json'
+    edit_permission = 'system.manage'
+
+
+@view_config(name='properties.json', context=IPortalTemplateContainer, request_type=IPyAMSLayer,
+             permission='system.manage', renderer='json', xhr=True)
+class PortalTemplateContainerPropertiesAJAXEditForm(AJAXEditForm, PortalTemplateContainerPropertiesEditForm):
+    """Portal template container properties edit form, JSON renderer"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/interfaces.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,30 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_form.interfaces.form import IForm
+from pyams_skin.interfaces.viewlet import IMenuItem
+
+# import packages
+
+
+class IPortalTemplateContainerMenu(IMenuItem):
+    """Portal template container menu interface"""
+
+
+class IPortletConfigurationEditor(IForm):
+    """Portlet configuration editor interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/portlet.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,63 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortlet
+from z3c.form.interfaces import HIDDEN_MODE
+
+# import packages
+from pyams_zmi.form import AdminDialogEditForm
+from pyramid.url import resource_url
+from z3c.form import field
+
+from pyams_portal import _
+
+
+class PortletConfigurationEditor(AdminDialogEditForm):
+    """Base portlet configuration editor"""
+
+    @property
+    def title(self):
+        translate = self.request.localizer.translate
+        registry = self.request.registry
+        portlet = registry.queryUtility(IPortlet, name=self.context.portlet_name)
+        return translate(_("« {0} »  portal template - {1}")).format(self.context.__parent__.name,
+                                                                     translate(portlet.label))
+
+    legend = _("Edit portlet configuration")
+    dialog_class = 'modal-large'
+
+    interface = None
+    edit_permission = 'portal.templates.manage'
+
+    def get_form_action(self):
+        return resource_url(self.context.__parent__, self.request, self.request.view_name)
+
+    def get_ajax_handler(self):
+        return resource_url(self.context.__parent__, self.request, self.ajax_handler)
+
+    @property
+    def fields(self):
+        fields = field.Fields(self.interface)
+        if not self.getContent().can_inherit:
+            fields = fields.omit('inherit_parent')
+        return fields
+
+    def updateWidgets(self, prefix=None):
+        super(PortletConfigurationEditor, self).updateWidgets(prefix)
+        self.widgets['slot_name'].mode = HIDDEN_MODE
+        self.widgets['position'].mode = HIDDEN_MODE
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/portlets/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,20 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/portlets/context.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,51 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_pagelet.interfaces import IPagelet
+from pyams_portal.interfaces import IPortletPreviewer
+from pyams_portal.portlets.context.interfaces import IContextPortletConfiguration
+from pyams_skin.layer import IPyAMSLayer
+
+# import packages
+from pyams_form.form import AJAXEditForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_portal.portlet import PortletPreviewer
+from pyams_portal.zmi.portlet import PortletConfigurationEditor
+from pyams_template.template import template_config
+from pyams_utils.adapter import adapter_config
+from zope.interface import Interface
+
+
+@pagelet_config(name='properties.html', context=IContextPortletConfiguration, request_type=IPyAMSLayer,
+                permission='system.view')
+class ContextPortletConfigurationEditor(PortletConfigurationEditor):
+    """Context portlet configuration editor"""
+
+    interface = IContextPortletConfiguration
+
+
+@adapter_config(name='properties.json', context=(IContextPortletConfiguration, IPyAMSLayer), provides=IPagelet)
+class ContextPortletConfigurationAJAXEditor(AJAXEditForm, ContextPortletConfigurationEditor):
+    """Context portlet configuration editor, AJAX renderer"""
+
+
+@adapter_config(context=(Interface, IPyAMSLayer, Interface, IContextPortletConfiguration),
+                provides=IPortletPreviewer)
+@template_config(template='templates/context-preview.pt', layer=IPyAMSLayer)
+class ContextPortletPreviewer(PortletPreviewer):
+    """Context portlet previewer"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/portlets/image.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,51 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_portal.interfaces import IPortletPreviewer
+from pyams_portal.portlet import PortletPreviewer
+from pyams_template.template import template_config
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_pagelet.interfaces import IPagelet
+from pyams_portal.portlets.image.interfaces import IImagePortletConfiguration
+from pyams_skin.layer import IPyAMSLayer
+
+# import packages
+from pyams_form.form import AJAXEditForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_portal.zmi.portlet import PortletConfigurationEditor
+from pyams_utils.adapter import adapter_config
+from zope.interface import Interface
+
+
+@pagelet_config(name='properties.html', context=IImagePortletConfiguration, request_type=IPyAMSLayer,
+                permission='system.view')
+class ImagePortletConfigurationEditor(PortletConfigurationEditor):
+    """Image portlet configuration editor"""
+
+    interface = IImagePortletConfiguration
+
+
+@adapter_config(name='properties.json', context=(IImagePortletConfiguration, IPyAMSLayer), provides=IPagelet)
+class ImagePortletConfigurationAJAXEditor(AJAXEditForm, ImagePortletConfigurationEditor):
+    """Image portlet configuration editor, AJAX renderer"""
+
+
+@adapter_config(context=(Interface, IPyAMSLayer, Interface, IImagePortletConfiguration),
+                provides=IPortletPreviewer)
+@template_config(template='templates/image-preview.pt', layer=IPyAMSLayer)
+class ImagePortletPreviewer(PortletPreviewer):
+    """Image portlet previewer"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/portlets/templates/context-preview.pt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,1 @@
+This is my preview !!
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/portlets/templates/image-preview.pt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,32 @@
+<tal:var define="config view.configuration">
+	<tal:if condition="config.visible">
+		<tal:if condition="config.image">
+			<a class="fancybox margin-left-5" data-toggle
+			   data-ams-fancybox-type="image"
+			   tal:define="image config.image;
+						   thumbnails extension:thumbnails(image);
+						   target python:thumbnails.get_thumbnail('800x600', 'jpeg');"
+			   tal:attributes="href extension:absolute_url(target);">
+				<img class="thumbnail padding-5 margin-5"
+					 tal:define="thumbnail python:thumbnails.get_thumbnail('128x128', 'jpeg');"
+					 tal:attributes="src extension:absolute_url(thumbnail)" src="" alt="" />
+			</a>
+		</tal:if>
+		<tal:if condition="not:config.image">
+			<div class="text-center padding-y-5">
+				<span class="fa-stack fa-lg">
+					<i class="fa fa-picture-o fa-stack-1x"></i>
+					<i class="fa fa-ban fa-stack-2x text-danger"></i>
+				</span>
+			</div>
+		</tal:if>
+	</tal:if>
+	<tal:if condition="not:config.visible">
+		<div class="text-center padding-y-5">
+			<span class="fa-stack fa-lg">
+				<i class="fa fa-eye fa-stack-1x"></i>
+				<i class="fa fa-ban fa-stack-2x text-danger"></i>
+			</span>
+		</div>
+	</tal:if>
+</tal:var>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/template/__init__.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,122 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_skin.interfaces import IContentTitle
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortalTemplateContainer, IPortalTemplate
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_workflow.interfaces import IWorkflowVersions, IWorkflowInfo
+from pyams_zmi.layer import IAdminLayer
+from z3c.form.interfaces import IDataExtractedEvent
+from zope.component.interfaces import ISite
+
+# import packages
+from pyams_form.form import AJAXAddForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_portal.template import PortalTemplate, PortalWfTemplate
+from pyams_portal.zmi.container import PortalTemplateContainerTable
+from pyams_skin.container import delete_container_element
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_utils.registry import query_utility
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm
+from pyramid.events import subscriber
+from pyramid.view import view_config
+from z3c.form import field
+from zope.interface import Interface, Invalid
+from zope.lifecycleevent import ObjectCreatedEvent
+
+from pyams_portal import _
+
+
+@adapter_config(context=(IPortalTemplate, IPyAMSLayer, Interface), provides=IContentTitle)
+class PortalTemplateTitleAdapter(ContextRequestViewAdapter):
+    """Portal template title adapter"""
+
+    @property
+    def title(self):
+        translate = self.request.localizer.translate
+        return translate(_("« {0} »  portal template")).format(self.context.name)
+
+
+#
+# Template views
+#
+
+@viewlet_config(name='add-portal-template.menu', context=ISite, layer=IAdminLayer,
+                view=PortalTemplateContainerTable, manager=IToolbarAddingMenu,
+                permission='portal.templates.manage', weight=20)
+class PortalTemplateAddMenu(ToolbarMenuItem):
+    """Portal template add menu"""
+
+    label = _("Add shared template...")
+    label_css_class = 'fa fa-fw fa-columns'
+    url = 'add-portal-template.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-portal-template.html', context=ISite, layer=IPyAMSLayer,
+                permission='portal.templates.manage')
+class PortalTemplateAddForm(AdminDialogAddForm):
+    """Portal template add form"""
+
+    title = _("Portal templates")
+    legend = _("Add shared template")
+    icon_css_class = 'fa fa-fw fa-columns'
+
+    fields = field.Fields(IPortalTemplate)
+    ajax_handler = 'add-portal-template.json'
+    edit_permission = None
+
+    def create(self, data):
+        return PortalTemplate()
+
+    def add(self, template):
+        wf_template = PortalWfTemplate()
+        self.request.registry.notify(ObjectCreatedEvent(wf_template))
+        context = query_utility(IPortalTemplateContainer)
+        context[template.name] = wf_template
+        IWorkflowVersions(wf_template).add_version(template, None)
+        IWorkflowInfo(template).fire_transition('init')
+
+    def nextURL(self):
+        return absolute_url(self.context, self.request, 'portal-templates.html')
+
+
+@subscriber(IDataExtractedEvent, form_selector=PortalTemplateAddForm)
+def handle_new_template_data_extraction(event):
+    """Handle new template form data extraction"""
+    container = query_utility(IPortalTemplateContainer)
+    name = event.data.get('name')
+    if name in container:
+        event.form.widgets.errors += (Invalid(_("Specified name is already used!")),)
+
+
+@view_config(name='add-portal-template.json', context=ISite, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplateAJAXAddForm(AJAXAddForm, PortalTemplateAddForm):
+    """Portal template add form, AJAX handler"""
+
+
+@view_config(name='delete-element.json', context=IPortalTemplateContainer, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def delete_portal_template(request):
+    """Delete template from portal"""
+    return delete_container_element(request)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/template/config.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,483 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyams_skin.page import DefaultPageHeaderAdapter
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import json
+
+# import interfaces
+from pyams_pagelet.interfaces import IPagelet, PageletCreatedEvent
+from pyams_portal.interfaces import IPortalTemplate, IPortalTemplateConfiguration, ISlot, \
+    IPortletAddingInfo, IPortlet, ISlotConfiguration, IPortletPreviewer, IPortalTemplateContainer, \
+    IPortalTemplateContainerConfiguration
+from pyams_skin.interfaces import IInnerPage, IPageHeader, IContentTitle
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_workflow.interfaces import IWorkflowState, IWorkflowVersions
+from pyams_zmi.interfaces.menu import ISiteManagementMenu, IPropertiesMenu
+from pyams_zmi.layer import IAdminLayer
+from transaction.interfaces import ITransactionManager
+from z3c.form.interfaces import IDataExtractedEvent, HIDDEN_MODE
+
+# import packages
+from pyams_form.form import AJAXAddForm, AJAXEditForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_portal.workflow import STATUS_LABELS, STATUS_IDS, PUBLISHED, ARCHIVED
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_skin.viewlet.toolbar import JsToolbarMenuItem, ToolbarMenuDivider, ToolbarMenuItem
+from pyams_template.template import template_config
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
+from pyams_utils.registry import query_utility
+from pyams_viewlet.manager import viewletmanager_config
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyams_zmi.view import AdminView
+from pyramid.decorator import reify
+from pyramid.events import subscriber
+from pyramid.exceptions import NotFound
+from pyramid.view import view_config
+from z3c.form import field
+from zope.interface import implementer, Invalid
+
+from pyams_portal import _
+
+
+@viewlet_config(name='template-properties.menu', context=IPortalTemplate, layer=IAdminLayer,
+                manager=ISiteManagementMenu, permission='system.view', weight=1)
+@viewletmanager_config(name='template-properties.menu', layer=IAdminLayer, provides=IPropertiesMenu)
+@implementer(IPropertiesMenu)
+class PortalTemplatePropertiesMenu(MenuItem):
+    """Portal template properties menu"""
+
+    label = _("Properties")
+    icon_class = 'fa-twitch'
+    url = '#properties.html'
+
+
+@pagelet_config(name='properties.html', context=IPortalTemplate, layer=IPyAMSLayer, permission='system.view')
+@template_config(template='templates/config.pt', layer=IAdminLayer)
+@implementer(IInnerPage)
+class PortalTemplateConfigView(AdminView):
+    """Portal template configuration view"""
+
+    title = _("Shared portal template configuration")
+
+    def get_context(self):
+        return self.context
+
+    @property
+    def can_change(self):
+        return self.request.has_permission('portal.templates.manage') and \
+               IWorkflowState(self.get_context()).state not in (PUBLISHED, ARCHIVED)
+
+    @reify
+    def configuration(self):
+        return IPortalTemplateConfiguration(self.get_context())
+
+    @property
+    def selected_portlets(self):
+        container = query_utility(IPortalTemplateContainer)
+        configuration = IPortalTemplateContainerConfiguration(container)
+        return [query_utility(IPortlet, name=portlet_name) for portlet_name in configuration.selected_portlets or ()]
+
+    def get_portlet(self, name):
+        return self.request.registry.getUtility(IPortlet, name=name)
+
+    def get_portlet_label(self, name):
+        return self.request.localizer.translate(self.get_portlet(name).label)
+
+    def get_portlet_preview(self, slot_name, position):
+        portlet_config = self.configuration.get_portlet_configuration(slot_name, position)
+        previewer = self.request.registry.queryMultiAdapter((self.get_context(), self.request, self, portlet_config),
+                                                            IPortletPreviewer)
+        if previewer is not None:
+            previewer.update()
+            return previewer.render()
+        else:
+            return ''
+
+
+@adapter_config(context=(IPortalTemplate, IAdminLayer, PortalTemplateConfigView), provides=IPageHeader)
+class PortalTemplateConfigHeaderAdapter(DefaultPageHeaderAdapter):
+    """Portal template configuration header adapter"""
+
+    back_url = '/admin.html#portal-templates.html'
+    back_target = None
+
+    icon_class = 'fa fa-fw fa-columns'
+    subtitle = _("Portlets configuration")
+
+
+#
+# Rows views
+#
+
+@viewlet_config(name='add-template-row.menu', context=IPortalTemplate, layer=IAdminLayer,
+                view=PortalTemplateConfigView, manager=IToolbarAddingMenu,
+                permission='portal.templates.manage', weight=1)
+class PortalTemplateRowAddMenu(JsToolbarMenuItem):
+    """Portal template row add menu"""
+
+    label = _("Add row...")
+    label_css_class = 'fa fa-fw fa-indent'
+    url = 'PyAMS_portal.template.addRow'
+
+
+@view_config(name='add-template-row.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def add_template_row(request):
+    """Add template raw"""
+    config = IPortalTemplateConfiguration(request.context)
+    return {'row_id': config.add_row()}
+
+
+@view_config(name='set-template-row-order.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def set_template_row_order(request):
+    """Set template rows order"""
+    config = IPortalTemplateConfiguration(request.context)
+    row_ids = map(int, json.loads(request.params.get('rows')))
+    config.set_row_order(row_ids)
+    return {'status': 'success'}
+
+
+@view_config(name='delete-template-row.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def delete_template_row(request):
+    """Delete template row"""
+    config = IPortalTemplateConfiguration(request.context)
+    config.delete_row(int(request.params.get('row_id')))
+    return {'status': 'success'}
+
+
+#
+# Slots views
+#
+
+@viewlet_config(name='add-template-slot.menu', context=IPortalTemplate, layer=IAdminLayer,
+                view=PortalTemplateConfigView, manager=IToolbarAddingMenu,
+                permission='portal.templates.manage', weight=2)
+class PortalTemplateSlotAddMenu(ToolbarMenuItem):
+    """Portal template slot add menu"""
+
+    label = _("Add slot...")
+    label_css_class = 'fa fa-fw fa-columns'
+    url = 'add-template-slot.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-template-slot.html', context=IPortalTemplate, layer=IPyAMSLayer,
+                permission='portal.templates.manage')
+class PortalTemplateSlotAddForm(AdminDialogAddForm):
+    """Portal template slot add form"""
+
+    @property
+    def title(self):
+        translate = self.request.localizer.translate
+        return translate(_("« {0} »  portal template")).format(self.context.name)
+
+    legend = _("Add slot")
+    icon_css_class = 'fa fa-fw fa-columns'
+
+    fields = field.Fields(ISlot)
+    ajax_handler = 'add-template-slot.json'
+    edit_permission = None
+
+    def updateWidgets(self, prefix=None):
+        super(PortalTemplateSlotAddForm, self).updateWidgets()
+        self.widgets['row_id'].value = self.request.params.get('form.widgets.row_id')
+
+    def createAndAdd(self, data):
+        config = IPortalTemplateConfiguration(self.context)
+        return config.add_slot(data.get('name'), data.get('row_id'))
+
+
+@subscriber(IDataExtractedEvent, form_selector=PortalTemplateSlotAddForm)
+def handle_new_slot_data_extraction(event):
+    """Handle new slot form data extraction"""
+    config = IPortalTemplateConfiguration(event.form.context)
+    name = event.data.get('name')
+    if name in config.slot_names:
+        event.form.widgets.errors += (Invalid(_("Specified name is already used!")),)
+
+
+@view_config(name='add-template-slot.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplateSlotAJAXAddForm(AJAXAddForm, PortalTemplateSlotAddForm):
+    """Portal template slot add form, AJAX handler"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'callback',
+                'callback': 'PyAMS_portal.template.addSlotCallback',
+                'options': {'row_id': changes[0],
+                            'slot_name': changes[1]}}
+
+
+@view_config(name='set-template-slot-order.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def set_template_slot_order(request):
+    """Set template slots order"""
+    config = IPortalTemplateConfiguration(request.context)
+    order = json.loads(request.params.get('order'))
+    for key in order.copy().keys():
+        order[int(key)] = order.pop(key)
+    config.set_slot_order(order)
+    return {'status': 'success'}
+
+
+@view_config(name='get-slots-width.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='system.view', renderer='json', xhr=True)
+def get_template_slots_width(request):
+    """Get template slots width"""
+    config = IPortalTemplateConfiguration(request.context)
+    return config.get_slots_width(request.params.get('device'))
+
+
+@view_config(name='set-slot-width.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def set_template_slot_width(request):
+    """Set template slot width"""
+    config = IPortalTemplateConfiguration(request.context)
+    config.set_slot_width(request.params.get('slot_name'),
+                          request.params.get('device'),
+                          int(request.params.get('width')))
+    return config.get_slots_width(request.params.get('device'))
+
+
+@pagelet_config(name='slot-properties.html', context=IPortalTemplate, layer=IPyAMSLayer, permission='system.view')
+class PortalTemplateSlotPropertiesEditForm(AdminDialogEditForm):
+    """Slot properties edit form"""
+
+    @property
+    def title(self):
+        translate = self.request.localizer.translate
+        return translate(_("« {0} »  portal template - {1} slot")).format(self.context.name,
+                                                                          self.getContent().slot_name)
+
+    legend = _("Edit slot properties")
+
+    label_css_class = 'control-label col-md-5'
+    input_css_class = 'col-md-7'
+
+    @property
+    def fields(self):
+        fields = field.Fields(ISlotConfiguration)
+        if not self.getContent().can_inherit:
+            fields = fields.omit('inherit_parent')
+        return fields
+
+    ajax_handler = 'slot-properties.json'
+    edit_permission = 'portal.templates.manage'
+
+    def __init__(self, context, request):
+        super(PortalTemplateSlotPropertiesEditForm, self).__init__(context, request)
+        self.config = IPortalTemplateConfiguration(context)
+
+    def getContent(self):
+        slot_name = self.request.params.get('form.widgets.slot_name')
+        return self.config.slot_config[slot_name]
+
+    def updateWidgets(self, prefix=None):
+        super(PortalTemplateSlotPropertiesEditForm, self).updateWidgets(prefix)
+        self.widgets['slot_name'].mode = HIDDEN_MODE
+
+
+@view_config(name='slot-properties.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplateSlotPropertiesAJAXEditForm(AJAXEditForm, PortalTemplateSlotPropertiesEditForm):
+    """Slot properties edit form, AJAX renderer"""
+
+    def get_ajax_output(self, changes):
+        if changes:
+            slot_name = self.widgets['slot_name'].value
+            slot_config = self.config.slot_config[slot_name]
+            return {'status': 'success',
+                    'callback': 'PyAMS_portal.template.editSlotCallback',
+                    'options': {'slot_name': slot_name,
+                                'width': slot_config.get_width()}}
+        else:
+            return super(PortalTemplateSlotPropertiesAJAXEditForm, self).get_ajax_output(changes)
+
+
+@view_config(name='delete-template-slot.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def delete_template_slot(request):
+    """Delete template slot"""
+    config = IPortalTemplateConfiguration(request.context)
+    config.delete_slot(request.params.get('slot_name'))
+    return {'status': 'success'}
+
+
+#
+# Portlet views
+#
+
+@viewlet_config(name='add-template-portlet.divider', context=IPortalTemplate, layer=IAdminLayer,
+                view=PortalTemplateConfigView, manager=IToolbarAddingMenu,
+                permission='portal.templates.manage', weight=10)
+class PortalTemplateAddMenuDivider(ToolbarMenuDivider):
+    """Portal template menu divider"""
+
+
+@viewlet_config(name='add-template-portlet.menu', context=IPortalTemplate, layer=IAdminLayer,
+                view=PortalTemplateConfigView, manager=IToolbarAddingMenu,
+                permission='portal.templates.manage', weight=20)
+class PortalTemplatePortletAddMenu(ToolbarMenuItem):
+    """Portal template portlet add menu"""
+
+    label = _("Add portlet...")
+    label_css_class = 'fa fa-fw fa-columns'
+    url = 'add-template-portlet.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-template-portlet.html', context=IPortalTemplate, layer=IPyAMSLayer,
+                permission='portal.templates.manage')
+class PortalTemplatePortletAddForm(AdminDialogAddForm):
+    """Portal template portlet add form"""
+
+    @property
+    def title(self):
+        translate = self.request.localizer.translate
+        return translate(_("« {0} »  portal template")).format(self.context.name)
+
+    legend = _("Add portlet")
+    icon_css_class = 'fa fa-fw fa-columns'
+
+    fields = field.Fields(IPortletAddingInfo)
+    ajax_handler = 'add-template-portlet.json'
+    edit_permission = None
+
+    def createAndAdd(self, data):
+        config = IPortalTemplateConfiguration(self.context)
+        return config.add_portlet(data.get('portlet_name'), data.get('slot_name'))
+
+
+@view_config(name='add-template-portlet.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplatePortletAJAXAddForm(AJAXAddForm, PortalTemplatePortletAddForm):
+    """Portal template portlet add form, AJAX handler"""
+
+    def get_ajax_output(self, changes):
+        config = IPortalTemplateConfiguration(self.context)
+        portlet_config = config.get_portlet_configuration(changes['slot_name'], changes['position'])
+        previewer = self.request.registry.queryMultiAdapter((self.context, self.request, self, portlet_config),
+                                                            IPortletPreviewer)
+        if previewer is not None:
+            previewer.update()
+            changes['preview'] = previewer.render()
+        return {'status': 'callback',
+                'callback': 'PyAMS_portal.template.addPortletCallback',
+                'options': changes}
+
+
+@view_config(name='drag-template-portlet.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def drag_template_portlet(request):
+    """Drag portlet icon to slot"""
+    config = IPortalTemplateConfiguration(request.context)
+    portlet_name = request.params.get('portlet_name')
+    slot_name = request.params.get('slot_name')
+    changes = config.add_portlet(portlet_name, slot_name)
+    portlet_config = config.get_portlet_configuration(changes['slot_name'], changes['position'])
+    previewer = request.registry.queryMultiAdapter((request.context, request, request, portlet_config),
+                                                   IPortletPreviewer)
+    if previewer is not None:
+        previewer.update()
+        changes['preview'] = previewer.render()
+    return {'status': 'callback',
+            'close_form': False,
+            'callback': 'PyAMS_portal.template.addPortletCallback',
+            'options': changes}
+
+
+@view_config(name='set-template-portlet-order.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def set_template_portlet_order(request):
+    """Set template portlet order"""
+    config = IPortalTemplateConfiguration(request.context)
+    order = json.loads(request.params.get('order'))
+    order['from']['position'] = int(order['from']['position'])
+    order['to']['positions'] = list(map(int, order['to']['positions']))
+    config.set_portlet_order(order)
+    return {'status': 'success'}
+
+
+@view_config(name='portlet-properties.html', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='system.view')
+class PortalTemplatePortletEditForm(AdminDialogEditForm):
+    """Portal template portlet edit form"""
+
+    dialog_class = 'modal-large'
+
+    def __call__(self):
+        request = self.request
+        request.registry.notify(PageletCreatedEvent(self))
+        slot_name = request.params.get('form.widgets.slot_name')
+        position = int(request.params.get('form.widgets.position'))
+        config = IPortalTemplateConfiguration(self.context)
+        portlet_config = config.get_portlet_configuration(slot_name, position)
+        if portlet_config is None:
+            raise NotFound()
+        editor = self.request.registry.queryMultiAdapter((portlet_config, request),
+                                                         IPagelet, name='properties.html')
+        if editor is None:
+            raise NotFound()
+        editor.ajax_handler = 'portlet-properties.json'
+        editor.update()
+        return editor()
+
+
+@view_config(name='portlet-properties.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplatePortletAJAXEditForm(AJAXEditForm, PortalTemplatePortletEditForm):
+    """Portal template portlet edit form, AJAX renderer"""
+
+    def __call__(self):
+        request = self.request
+        request.registry.notify(PageletCreatedEvent(self))
+        slot_name = request.params.get('form.widgets.slot_name')
+        position = int(request.params.get('form.widgets.position'))
+        config = IPortalTemplateConfiguration(self.context)
+        portlet_config = config.get_portlet_configuration(slot_name, position)
+        if portlet_config is None:
+            raise NotFound()
+        editor = request.registry.queryMultiAdapter((portlet_config, request),
+                                                    IPagelet, name='properties.json')
+        if editor is None:
+            raise NotFound()
+        changes = editor()
+        if changes:
+            # we commit before loading previewer to avoid BLOBs "uncommited changes" error
+            ITransactionManager(self.context).commit()
+            previewer = request.registry.queryMultiAdapter((self.context, request, self, portlet_config),
+                                                           IPortletPreviewer)
+            if previewer is not None:
+                previewer.update()
+                changes.update({'status': 'callback',
+                                'callback': 'PyAMS_portal.template.editPortletCallback',
+                                'options': {'slot_name': slot_name,
+                                            'position': position,
+                                            'preview': previewer.render()}})
+        return changes
+
+
+@view_config(name='delete-template-portlet.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+def delete_template_portlet(request):
+    """Delete template portlet"""
+    config = IPortalTemplateConfiguration(request.context)
+    config.delete_portlet(request.params.get('slot_name'), int(request.params.get('position')))
+    return {'status': 'success'}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/template/page.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,153 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+from pyramid.exceptions import NotFound
+from pyramid.view import view_config
+from pyams_form.form import AJAXEditForm
+from pyams_pagelet.interfaces import PageletCreatedEvent, IPagelet
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_portal.interfaces import IPortalContext, IPortalPage, IPortalTemplateConfiguration
+from pyams_portal.workflow import PUBLISHED, ARCHIVED
+from pyams_portal.zmi.template.config import PortalTemplateConfigView
+from pyams_skin.interfaces import IInnerPage
+from pyams_skin.layer import IPyAMSLayer
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_template.template import template_config
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_workflow.interfaces import IWorkflowVersions, IWorkflowState
+from pyams_zmi.form import AdminDialogEditForm
+from pyams_zmi.interfaces.menu import ISiteManagementMenu, IPropertiesMenu
+from pyams_zmi.layer import IAdminLayer
+from pyams_zmi.view import AdminView
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+
+# import packages
+from z3c.form import field
+from zope.interface import implementer
+
+from pyams_portal import _
+
+
+
+@viewlet_config(name='template-properties.menu', context=IPortalContext, layer=IAdminLayer,
+                manager=IPropertiesMenu, permission='manage', weight=5)
+class PortalContextTemplatePropertiesMenu(MenuItem):
+    """Portal context template properties menu"""
+
+    label = _("Presentation template...")
+    icon_class = 'fa-columns'
+
+    url = 'template-properties.html'
+    modal_target = True
+
+
+@pagelet_config(name='template-properties.html', context=IPortalContext, layer=IPyAMSLayer, permission='manage')
+class PortalContextTemplatePropertiesEditForm(AdminDialogEditForm):
+    """Portal context template properties edit form"""
+
+    @property
+    def title(self):
+        return self.context.title
+
+    legend = _("Edit template configuration")
+
+    @property
+    def fields(self):
+        fields = field.Fields(IPortalPage).select('inherit_parent', 'use_local_template', 'shared_template')
+        if not self.getContent().can_inherit:
+            fields = fields.omit('inherit_parent')
+        return fields
+
+    ajax_handler = 'template-properties.json'
+    edit_permission = 'manage'
+
+    def getContent(self):
+        return IPortalPage(self.context)
+
+
+@view_config(name='template-properties.json', context=IPortalContext, request_type=IPyAMSLayer,
+             permission='manage', renderer='json', xhr=True)
+class PortalContextTemplatePropertiesAJAXEditForm(AJAXEditForm, PortalContextTemplatePropertiesEditForm):
+    """Portal context template properties edit form, JSON renderer"""
+
+
+@viewlet_config(name='template-config.menu', context=IPortalContext, layer=IAdminLayer,
+                manager=ISiteManagementMenu, permission='manage', weight=20)
+class PortalContextTemplateConfigMenu(MenuItem):
+    """Portal context template configuration menu"""
+
+    label = _("Template properties")
+    icon_class = 'fa-columns'
+
+    url = '#template-config.html'
+
+    def __new__(cls, context, request, view, manager=None):
+        page = IPortalPage(context)
+        if page.template is None:
+            return None
+        return MenuItem.__new__(cls)
+
+    def get_url(self):
+        page = IPortalPage(self.context)
+        if page.use_local_template:
+            template = IWorkflowVersions(page.template).get_last_versions()[0]
+            return absolute_url(template, self.request, 'admin.html#properties.html')
+        else:
+            return super(PortalContextTemplateConfigMenu, self).get_url()
+
+
+@pagelet_config(name='template-config.html', context=IPortalContext, layer=IPyAMSLayer, permission='manage')
+class PortalContextTemplateConfigView(PortalTemplateConfigView):
+    """Portal context template configuration view"""
+
+    title = _("Local portal template configuration")
+
+    def get_context(self):
+        template = IPortalPage(self.context).template
+        return IWorkflowVersions(template).get_last_versions()[0]
+
+    @property
+    def can_change(self):
+        if not IPortalPage(self.context).use_local_template:
+            return False
+        return self.request.has_permission('manage') and \
+               IWorkflowState(self.get_context()).state not in (PUBLISHED, ARCHIVED)
+
+
+@view_config(name='portlet-properties.html', context=IPortalContext, request_type=IPyAMSLayer, permission='manage')
+class PortalContextTemplatePortletEditForm(AdminDialogEditForm):
+    """Portal context template portlet edit form"""
+
+    dialog_class = 'modal-large'
+
+    def __call__(self):
+        request = self.request
+        request.registry.notify(PageletCreatedEvent(self))
+        slot_name = request.params.get('form.widgets.slot_name')
+        position = int(request.params.get('form.widgets.position'))
+        config = IPortalTemplateConfiguration(self.context)
+        portlet_config = config.get_portlet_configuration(slot_name, position)
+        if portlet_config is None:
+            raise NotFound()
+        editor = self.request.registry.queryMultiAdapter((portlet_config, request),
+                                                         IPagelet, name='properties.html')
+        if editor is None:
+            raise NotFound()
+        editor.ajax_handler = 'portlet-properties.json'
+        editor.update()
+        return editor()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/template/templates/config.pt	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,160 @@
+<tal:var define="config view.configuration" i18n:domain="pyams_portal">
+	<div class="ams-widget"
+		 data-ams-plugins="pyams_portal"
+		 data-ams-plugin-pyams_portal-src="/--static--/pyams_portal/js/portal{MyAMS.devext}.js"
+		 data-ams-plugin-pyams_portal-css="/--static--/pyams_portal/css/portal{MyAMS.devext}.css"
+		 data-ams-plugin-pyams_portal-callback="PyAMS_portal.template.initConfig">
+		<header>
+			<span tal:condition="view.widget_icon_class | nothing"
+				  class="widget-icon"><i tal:attributes="class view.widget_icon_class"></i>
+			</span>
+			<h2 tal:content="view.title">Title</h2>
+			<tal:var content="structure provider:pyams.widget_title" />
+			<tal:var content="structure provider:pyams.toolbar" />
+		</header>
+		<div class="widget-body" tal:define="can_change view.can_change">
+			<div class="btn-toolbar" role="toolbar"
+				 tal:condition="can_change">
+				<div class="btn-group" role="group">
+					<div class="btn btn-default btn-row hint" title="Add row" i18n:attributes="title"
+						 data-ams-hint-gravity="n">
+						<i class="fa fa-fw fa-2x fa-indent"></i>
+					</div>
+					<div class="btn btn-default btn-slot hint" title="Add slot" i18n:attributes="title"
+						 data-ams-hint-gravity="n">
+						<i class="fa fa-fw fa-2x fa-columns"></i>
+					</div>
+				</div>
+				<div class="btn-group" role="group">
+					<div tal:repeat="portlet view.selected_portlets"
+						 class="btn btn-default btn-portlet hint"
+						 data-ams-hint-gravity="n"
+						 tal:attributes="data-ams-portlet-name portlet.name;
+										 title portlet.label;">
+						<img tal:condition="portlet.toolbar_image"
+							 tal:attributes="src portlet.toolbar_image" />
+						<i tal:condition="portlet.toolbar_css_class"
+						   tal:attributes="class portlet.toolbar_css_class"></i>
+					</div>
+				</div>
+				<div class="btn-group" role="group">
+					<div class="btn btn-default hint" data-ams-url="add-template-portlet.html" data-toggle="modal"
+						 data-ams-hint-gravity="n"
+						 title="Add another portlet..." i18n:attributes="title">
+						<i class="fa fa-fw fa-2x fa-plus"></i>
+					</div>
+				</div>
+			</div>
+			<div class="clearfix">
+				<div class="ams-form form-horizontal margin-bottom-10">
+					<label class="control-label col-md-6 padding-right-5" i18n:translate="">Selected display:</label>
+					<div class="col-md-5">
+						<select id="device_selector" class="select2"
+								data-ams-select2-width="300px"
+								data-ams-change-handler="PyAMS_portal.template.selectDisplay">
+							<option value="" selected i18n:translate="">Current device</option>
+							<option value="xs" i18n:translate="">Extra small device (phone)</option>
+							<option value="sm" i18n:translate="">Small device (tablet)</option>
+							<option value="md" i18n:translate="">Medium desktop device (> 970px)</option>
+							<option value="lg" i18n:translate="">Large desktop device (> 1170px)</option>
+						</select>
+					</div>
+				</div>
+			</div>
+			<div id="portal_config" class="container"
+				 tal:attributes="data-ams-allowed-change can_change">
+				<div class="rows"
+					 data-ams-sortable-placeholder="row-highlight"
+					 data-ams-sortable-items="> .row"
+					 data-ams-sortable-over="PyAMS_portal.template.overRows"
+					 data-ams-sortable-stop="PyAMS_portal.template.sortRows">
+					<div class="row context-menu"
+						 data-ams-contextmenu-selector="#rowMenu"
+						 tal:repeat="row range(config.rows)"
+						 tal:attributes="data-ams-row-id row;">
+						<span class="row_id label label-success pull-right"
+							  tal:content="row"></span>
+						<div class="slots"
+							 data-ams-sortable-placeholder="slot-highlight"
+							 data-ams-sortable-connectwith=".slots"
+							 data-ams-sortable-over="PyAMS_portal.template.overSlots"
+							 data-ams-sortable-stop="PyAMS_portal.template.sortSlots">
+							<div class="slot context-menu col col-md-12 no-padding"
+								 data-ams-contextmenu-selector="#slotMenu"
+								 data-ams-resizable-start="PyAMS_portal.template.startSlotResize"
+								 data-ams-resizable-stop="PyAMS_portal.template.stopSlotResize"
+								 data-ams-resizable-handles="e"
+								 tal:repeat="slot_name config.get_slots(row)"
+								 tal:attributes="class string:slot context-menu col ${config.get_slot_configuration(slot_name).get_css_class()};
+												 data-ams-slot-name slot_name;">
+								<div class="header padding-x-5"
+									 tal:content="slot_name"></div>
+								<div class="portlets"
+									 data-ams-sortable-placeholder="portlet-highlight"
+									 data-ams-sortable-connectwith=".portlets"
+									 data-ams-sortable-over="PyAMS_portal.template.overPortlets"
+									 data-ams-sortable-stop="PyAMS_portal.template.sortPortlets">
+									<div class="portlet context-menu"
+										 data-ams-contextmenu-selector="#portletMenu"
+										 tal:repeat="portlet_name config.slots.get(row,{}).get(slot_name, ())"
+										 tal:attributes="data-ams-portlet-name portlet_name;
+														 data-ams-portlet-slot slot_name;
+														 data-ams-portlet-position repeat['portlet_name'].index();">
+										<div class="header padding-x-5"
+											 tal:content="string:${view.get_portlet_label(portlet_name)}"></div>
+										<div class="preview"
+											 tal:content="structure view.get_portlet_preview(slot_name, repeat['portlet_name'].index())"></div>
+									</div>
+								</div>
+								<div class="clearfix"></div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<ul id="rowMenu" class="dropdown-menu" role="menu" style="display:none;"
+				tal:condition="can_change">
+				<li class="small">
+					<a tabindex="-1" data-ams-url="PyAMS_portal.template.deleteRow">
+						<i class="fa fa-fw fa-trash"></i>
+						<i18n:var translate="">Delete row...</i18n:var>
+					</a>
+				</li>
+			</ul>
+			<ul id="slotMenu" class="dropdown-menu" role="menu" style="display:none;" >
+				<li class="small">
+					<a tabindex="-1" data-ams-url="PyAMS_portal.template.editSlot">
+						<i class="fa fa-fw fa-edit"></i>
+						<i18n:var translate="">Edit slot properties...</i18n:var>
+					</a>
+				</li>
+				<tal:if condition="can_change">
+					<li class="divider"></li>
+					<li class="small" tal:condition="can_change">
+						<a tabindex="-1" data-ams-url="PyAMS_portal.template.deleteSlot">
+							<i class="fa fa-fw fa-trash"></i>
+							<i18n:var translate="">Delete slot...</i18n:var>
+						</a>
+					</li>
+				</tal:if>
+			</ul>
+			<ul id="portletMenu" class="dropdown-menu" role="menu" style="display:none;" >
+				<li class="small">
+					<a tabindex="-1" data-ams-url="PyAMS_portal.template.editPortlet">
+						<i class="fa fa-fw fa-edit"></i>
+						<i18n:var translate="">Edit portlet properties...</i18n:var>
+					</a>
+				</li>
+				<tal:if condition="can_change">
+					<li class="divider"></li>
+					<li class="small">
+						<a tabindex="-1" data-ams-url="PyAMS_portal.template.deletePortlet">
+							<i class="fa fa-fw fa-trash"></i>
+							<i18n:var translate="">Delete portlet...</i18n:var>
+						</a>
+					</li>
+				</tal:if>
+			</ul>
+		</div>
+	</div>
+</tal:var>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/zmi/template/workflow.py	Wed Jun 17 09:58:33 2015 +0200
@@ -0,0 +1,176 @@
+#
+# Copyright (c) 2008-2015 Thierry Florac <tflorac AT ulthar.net>
+# All Rights Reserved.
+#
+# This software is subject to the provisions of the Zope Public License,
+# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
+# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
+# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
+# FOR A PARTICULAR PURPOSE.
+#
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_portal.interfaces import IPortalTemplate
+from pyams_skin.layer import IPyAMSLayer
+from pyams_workflow.interfaces import IWorkflowPublicationInfo, IWorkflowCommentInfo, IWorkflowInfo, \
+    IWorkflowTransitionInfo
+
+# import packages
+from pyams_form.form import AJAXAddForm
+from pyams_form.schema import CloseButton
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_utils.url import absolute_url
+from pyams_workflow.zmi.transition import WorkflowContentTransitionForm
+from pyramid.view import view_config
+from z3c.form import field, button
+from zope.interface import Interface
+from zope.lifecycleevent import ObjectModifiedEvent
+
+from pyams_portal import _
+
+
+#
+# Base workflow form
+#
+
+class PortalTemplateWorkflowForm(WorkflowContentTransitionForm):
+    """Base portal template workflow form"""
+
+
+#
+# Publish forms
+#
+
+class IPortalTemplatePublishButtons(Interface):
+    """Portal template publish buttons"""
+
+    close = CloseButton(name='close', title=_("Close"))
+    action = button.Button(name='action', title=_("Publish"))
+
+
+@pagelet_config(name='wf-publish.html', context=IPortalTemplate, layer=IPyAMSLayer,
+                permission='portal.templates.manage')
+class PortalTemplatePublishForm(PortalTemplateWorkflowForm):
+    """Portal template publish form"""
+
+    legend = _("Publish template")
+
+    fields = field.Fields(IWorkflowTransitionInfo) + \
+             field.Fields(IWorkflowPublicationInfo).select('publication_effective_date',
+                                                           'publication_expiration_date') + \
+             field.Fields(IWorkflowCommentInfo)
+    buttons = button.Buttons(IPortalTemplatePublishButtons)
+    ajax_handler = 'wf-publish.json'
+
+    def createAndAdd(self, data):
+        pub_info = IWorkflowPublicationInfo(self.context)
+        pub_info.publication_effective_date = data.get('publication_effective_date')
+        pub_info.publication_expiration_date = data.get('publication_expiration_date')
+        info = IWorkflowInfo(self.context)
+        info.fire_transition_toward('published', comment=data.get('comment'))
+        self.request.registry.notify(ObjectModifiedEvent(self.context))
+        return info
+
+
+@view_config(name='wf-publish.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplateAJAXPublishForm(AJAXAddForm, PortalTemplatePublishForm):
+    """Portal template publish form, AJAX renderer"""
+
+
+#
+# Retire form
+#
+
+class IPortalTemplateRetireButtons(Interface):
+    """Portal template retire buttons"""
+
+    close = CloseButton(name='close', title=_("Close"))
+    action = button.Button(name='action', title=_("Retire"))
+
+
+@pagelet_config(name='wf-retire.html', context=IPortalTemplate, layer=IPyAMSLayer,
+                permission='portal.templates.manage')
+class PortalTemplateRetireForm(PortalTemplateWorkflowForm):
+    """Portal template retire form"""
+
+    legend = _("Retire template")
+
+    buttons = button.Buttons(IPortalTemplateRetireButtons)
+    ajax_handler = 'wf-retire.json'
+
+
+@view_config(name='wf-retire.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplateAJAXRetireForm(AJAXAddForm, PortalTemplateRetireForm):
+    """Portal template retire form, AJAX renderer"""
+
+
+#
+# Archive form
+#
+
+class IPortalTemplateArchiveButtons(Interface):
+    """Portal template archive buttons"""
+
+    close = CloseButton(name='close', title=_("Close"))
+    action = button.Button(name='action', title=_("Archive"))
+
+
+@pagelet_config(name='wf-archive.html', context=IPortalTemplate, layer=IPyAMSLayer,
+                permission='portal.templates.manage')
+class PortalTemplateArchiveForm(PortalTemplateWorkflowForm):
+    """Portal template archive form"""
+
+    legend = _("Archive template")
+
+    buttons = button.Buttons(IPortalTemplateArchiveButtons)
+    ajax_handler = 'wf-archive.json'
+
+
+@view_config(name='wf-archive.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplateAJAXArchiveForm(AJAXAddForm, PortalTemplateArchiveForm):
+    """Portal template archive form, AJAX renderer"""
+
+
+#
+# Clone forms
+#
+
+class IPortalTemplateCloneButtons(Interface):
+    """Portal template clone buttons"""
+
+    close = CloseButton(name='close', title=_("Close"))
+    action = button.Button(name='action', title=_("Create new version"))
+
+
+@pagelet_config(name='wf-clone.html', context=IPortalTemplate, layer=IPyAMSLayer,
+                permission='portal.templates.manage')
+class PortalTemplateCloneForm(PortalTemplateWorkflowForm):
+    """Portal template clone form"""
+
+    legend = _("Create new version")
+
+    buttons = button.Buttons(IPortalTemplateCloneButtons)
+    ajax_handler = 'wf-clone.json'
+
+    def createAndAdd(self, data):
+        info = IWorkflowInfo(self.context)
+        return info.fire_transition_toward('draft', comment=data.get('comment'))
+
+
+@view_config(name='wf-clone.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+             permission='portal.templates.manage', renderer='json', xhr=True)
+class PortalTemplateAJAXCloneForm(AJAXAddForm, PortalTemplateCloneForm):
+    """Portal template clone form, AJAX renderer"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'redirect',
+                'location': absolute_url(changes, self.request, 'admin.html#properties.html')}