Added site management features
authorThierry Florac <tflorac@ulthar.net>
Sun, 26 Nov 2017 09:58:07 +0100
changeset 294 8742c8ac126c
parent 293 401794fc244b
child 295 fb84bba1880f
Added site management features
src/pyams_content/shared/site/__init__.py
src/pyams_content/shared/site/container.py
src/pyams_content/shared/site/folder.py
src/pyams_content/shared/site/interfaces/__init__.py
src/pyams_content/shared/site/link.py
src/pyams_content/shared/site/manager.py
src/pyams_content/shared/site/zmi/__init__.py
src/pyams_content/shared/site/zmi/container.py
src/pyams_content/shared/site/zmi/folder.py
src/pyams_content/shared/site/zmi/link.py
src/pyams_content/shared/site/zmi/manager.py
src/pyams_content/shared/site/zmi/widget/__init__.py
src/pyams_content/shared/site/zmi/widget/interfaces.py
src/pyams_content/shared/site/zmi/widget/templates/folders-input.pt
--- a/src/pyams_content/shared/site/__init__.py	Sun Nov 26 09:57:42 2017 +0100
+++ b/src/pyams_content/shared/site/__init__.py	Sun Nov 26 09:58:07 2017 +0100
@@ -20,10 +20,11 @@
 from pyams_content.component.theme.interfaces import IThemesTarget
 from pyams_content.features.preview.interfaces import IPreviewTarget
 from pyams_content.features.review.interfaces import IReviewTarget
-from pyams_content.shared.site.interfaces import IWfTopic, TOPIC_CONTENT_TYPE, TOPIC_CONTENT_NAME, ITopic
 
 # import packages
-from pyams_content.shared.common import register_content_type, WfSharedContent, SharedContent
+from pyams_content.shared.common import SharedContent, WfSharedContent, register_content_type
+from pyams_content.shared.site.interfaces import ISiteContainer, ISiteFolder, ITopic, IWfTopic, TOPIC_CONTENT_NAME, \
+    TOPIC_CONTENT_TYPE
 from zope.interface import implementer
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/container.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,84 @@
+#
+# 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 json
+
+# import interfaces
+from pyams_content.shared.site.interfaces import ISiteContainer, ISiteFolder
+from pyams_i18n.interfaces import II18n
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_utils.registry import get_utility
+from pyams_utils.request import query_request
+from pyramid.location import lineage
+from zope.interface import implementer
+
+
+@implementer(ISiteContainer)
+class SiteContainerMixin(object):
+    """Site container mixin class"""
+
+    def get_folders_tree(self, selected=None, permission=None):
+
+        request = query_request()
+        intids = get_utility(IIntIds)
+
+        def get_folder_items(parent, input):
+            for folder in parent.values():
+                if ISiteFolder.providedBy(folder):
+                    if permission is not None:
+                        can_select = request.has_permission(permission, context=folder)
+                    else:
+                        can_select = True
+                    value = {
+                        'id': intids.queryId(folder),
+                        'text': II18n(folder).query_attribute('title', request=request),
+                        'state': {
+                            'expanded': folder in lineage(self),
+                            'selected': folder is selected
+                        },
+                        'selectable': can_select
+                    }
+                    items = get_folder_items(folder, [])
+                    if items:
+                        value['nodes'] = items
+                    input.append(value)
+            return input
+
+        # get child folders
+        items = get_folder_items(self, [])
+
+        # get parents folders
+        container = self
+        while ISiteContainer.providedBy(container):
+            if permission is not None:
+                can_select = request.has_permission(permission, context=container)
+            else:
+                can_select = True
+            items = [{
+                'id': intids.queryId(container),
+                'text': II18n(container).query_attribute('title', request=request),
+                'state': {
+                    'expanded': True,
+                    'selected': container is selected
+                },
+                'selectable': can_select,
+                'nodes': items
+            }]
+            container = container.__parent__
+
+        return json.dumps(items)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/folder.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,78 @@
+#
+# 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_content.interfaces import MANAGE_SITE_PERMISSION
+from pyams_content.shared.site.interfaces import ISiteFolder, ISiteManager, ISiteFolderRoles
+from pyams_form.interfaces.form import IFormContextPermissionChecker
+from pyams_i18n.interfaces import II18n
+from pyams_portal.interfaces import IPortalContext
+from pyams_security.interfaces import IDefaultProtectionPolicy
+from zope.annotation.interfaces import IAttributeAnnotatable
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_content.shared.common.manager import BaseSharedTool
+from pyams_content.shared.site.container import SiteContainerMixin
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.container import find_objects_providing
+from pyams_utils.registry import get_local_registry
+from pyams_utils.request import query_request
+from pyams_utils.traversing import get_parent
+from pyams_utils.vocabulary import vocabulary_config
+from zope.container.ordered import OrderedContainer
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+from zope.schema.vocabulary import SimpleVocabulary, SimpleTerm
+
+
+@implementer(IDefaultProtectionPolicy, ISiteFolder, ISiteFolderRoles,
+             IPortalContext, IAttributeAnnotatable)
+class SiteFolder(SiteContainerMixin, OrderedContainer, BaseSharedTool):
+    """Site folder persistent class"""
+
+    roles_interface = ISiteFolderRoles
+
+    notepad = FieldProperty(ISiteFolder['notepad'])
+
+    sequence_name = ''  # use default sequence generator
+    sequence_prefix = ''
+
+
+@adapter_config(context=ISiteFolder, provides=IFormContextPermissionChecker)
+class SiteFolderPermissionChecker(ContextAdapter):
+    """Site folder edit permission checker"""
+
+    edit_permission = MANAGE_SITE_PERMISSION
+
+
+@vocabulary_config(name='PyAMS site folders')
+class SiteManagerFoldersVocabulary(SimpleVocabulary):
+    """Site manager folders vocabulary"""
+
+    def __init__(self, context):
+        terms = []
+        site = get_parent(context, ISiteManager)
+        if site is not None:
+            registry = get_local_registry()
+            if registry is not None:
+                request = query_request()
+                intids = registry.getUtility(IIntIds)
+                for folder in find_objects_providing(site, ISiteFolder):
+                    terms.append(SimpleTerm(value=intids.queryId(folder),
+                                            title=II18n(folder).query_attribute('title', request=request)))
+        super(SiteManagerFoldersVocabulary, self).__init__(terms)
--- a/src/pyams_content/shared/site/interfaces/__init__.py	Sun Nov 26 09:57:42 2017 +0100
+++ b/src/pyams_content/shared/site/interfaces/__init__.py	Sun Nov 26 09:58:07 2017 +0100
@@ -9,6 +9,7 @@
 # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
 # FOR A PARTICULAR PURPOSE.
 #
+from pyams_i18n.schema import I18nTextLineField
 
 __docformat__ = 'restructuredtext'
 
@@ -17,39 +18,61 @@
 
 # import interfaces
 from pyams_content.interfaces import IBaseContent
-from zope.container.interfaces import IContainer
+from pyams_sequence.interfaces import ISequentialIdTarget
+from pyams_workflow.interfaces import IWorkflowPublicationSupport
+from zope.container.interfaces import IContainer, IContained
 
 # import packages
-from pyams_content.shared.common.interfaces import ISharedSite, IWfSharedContent, ISharedContent
+from pyams_content.shared.common.interfaces import ISharedSite, IWfSharedContent, ISharedContent, \
+    IBaseContentManagerRoles, IBaseSharedTool
+from pyams_sequence.schema import InternalReference
 from zope.container.constraints import containers, contains
+from zope.interface import Attribute
+from zope.schema import Text
 
 from pyams_content import _
 
 
-class ISiteElement(IBaseContent):
+class ISiteElement(IContained):
     """Base site element interface"""
 
     containers('.ISiteContainer')
 
 
-class ISiteContainer(IBaseContent, IContainer):
+class ISiteContainer(IContainer, IContained, IWorkflowPublicationSupport):
     """Base site container interface"""
 
     contains(ISiteElement)
 
+    def get_folders_tree(self, selected=None):
+        """Get site tree in JSON format"""
 
-class ISiteFolder(ISiteElement, ISiteContainer):
+
+class ISiteFolder(IBaseContent, ISiteElement, ISiteContainer, ISequentialIdTarget):
     """Site folder interface
 
     A site folder is made to contain sub-folders and topics
     """
 
+    notepad = Text(title=_("Notepad"),
+                   description=_("Internal information to be known about this content"),
+                   required=False)
 
-class ISiteManager(ISharedSite, ISiteContainer):
+
+class ISiteFolderRoles(IBaseContentManagerRoles):
+    """Site folder roles interface"""
+
+
+class ISiteManager(ISharedSite, ISiteContainer, IBaseSharedTool, ISequentialIdTarget):
     """Site manager interface"""
 
     contains(ISiteElement)
 
+    folder_factory = Attribute("Folder factory")
+
+    topic_content_type = Attribute("Topic content type")
+    topic_content_factory = Attribute("Topic content factory")
+
 
 TOPIC_CONTENT_TYPE = 'topic'
 TOPIC_CONTENT_NAME = _("Topic")
@@ -61,3 +84,20 @@
 
 class ITopic(ISharedContent, ISiteElement):
     """Workflow managed topic interface"""
+
+
+class IContentLink(ISiteElement):
+    """Rented content interface"""
+
+    reference = InternalReference(title=_("Internal reference"),
+                                  description=_("Internal link target reference. You can search a reference using "
+                                                "'+' followed by internal number, of by entering text matching "
+                                                "content title."),
+                                  required=True)
+
+    alt_title = I18nTextLineField(title=_("Alternate title"),
+                                  description=_("Content title, as shown in front-office"),
+                                  required=False)
+
+    def get_target(self):
+        """Get reference target"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/link.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,60 @@
+#
+# 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_content.shared.site.interfaces import IContentLink
+from pyams_workflow.interfaces import IWorkflow, IWorkflowVersion, IWorkflowVersions, IWorkflowPublicationInfo
+
+# import packages
+from persistent import Persistent
+from pyams_sequence.utility import get_reference_target
+from pyams_utils.adapter import adapter_config
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+
+
+@implementer(IContentLink)
+class ContentLink(Persistent, Contained):
+    """Content link persistent class
+
+    A 'content link' is a link to another content, which may be stored anywhere (same site,
+    another site or in any shared tool).
+    """
+
+    reference = FieldProperty(IContentLink['reference'])
+    alt_title = FieldProperty(IContentLink['alt_title'])
+
+    def get_target(self):
+        target = get_reference_target(self.reference)
+        if IWorkflowVersion.providedBy(target):
+            workflow = IWorkflow(target, None)
+            if workflow is not None:
+                versions = IWorkflowVersions(target).get_versions(workflow.published_states, sort=True)
+                if not versions:
+                    versions = IWorkflowVersions(target).get_last_versions()
+                if versions:
+                    target = versions[-1]
+        return target
+
+
+@adapter_config(context=IContentLink, provides=IWorkflowPublicationInfo)
+def content_link_publication_info(context):
+    """Content link publication info"""
+    target = context.get_target()
+    if target is not None:
+        return IWorkflowPublicationInfo(target, None)
--- a/src/pyams_content/shared/site/manager.py	Sun Nov 26 09:57:42 2017 +0100
+++ b/src/pyams_content/shared/site/manager.py	Sun Nov 26 09:58:07 2017 +0100
@@ -18,7 +18,9 @@
 # import interfaces
 from pyams_content.component.paragraph.interfaces import IParagraphFactorySettings
 from pyams_content.component.theme.interfaces import IThemesManagerTarget
-from pyams_content.shared.site.interfaces import ISiteManager
+from pyams_content.interfaces import MANAGE_SITE_PERMISSION
+from pyams_content.shared.site.interfaces import ISiteManager, TOPIC_CONTENT_TYPE
+from pyams_form.interfaces.form import IFormContextPermissionChecker
 from pyams_i18n.interfaces import II18n
 from pyams_portal.interfaces import IPortalContext
 from zope.annotation.interfaces import IAttributeAnnotatable
@@ -27,7 +29,11 @@
 
 # import packages
 from pyams_content.shared.common.manager import BaseSharedTool
+from pyams_content.shared.site import Topic
+from pyams_content.shared.site.container import SiteContainerMixin
+from pyams_content.shared.site.folder import SiteFolder
 from pyams_skin.skin import UserSkinnableContent
+from pyams_utils.adapter import adapter_config, ContextAdapter
 from pyams_utils.registry import get_utilities_for
 from pyams_utils.request import query_request
 from pyams_utils.traversing import get_parent
@@ -39,13 +45,22 @@
 from zope.schema.vocabulary import SimpleTerm, SimpleVocabulary
 
 
-@implementer(ISiteManager, IParagraphFactorySettings, IThemesManagerTarget, IAttributeAnnotatable, IPortalContext)
-class SiteManager(OrderedContainer, BaseSharedTool, UserSkinnableContent):
+@implementer(ISiteManager, IParagraphFactorySettings, IThemesManagerTarget,
+             IPortalContext, IAttributeAnnotatable)
+class SiteManager(SiteContainerMixin, OrderedContainer, BaseSharedTool, UserSkinnableContent):
     """Site manager persistent class"""
 
+    folder_factory = SiteFolder
+
+    topic_content_type = TOPIC_CONTENT_TYPE
+    topic_content_factory = Topic
+
     allowed_paragraphs = FieldProperty(IParagraphFactorySettings['allowed_paragraphs'])
     auto_created_paragraphs = FieldProperty(IParagraphFactorySettings['auto_created_paragraphs'])
 
+    sequence_name = ''  # use default sequence generator
+    sequence_prefix = ''
+
 
 @subscriber(IObjectAddedEvent, context_selector=ISiteManager)
 def handle_added_site_manager(event):
@@ -65,6 +80,13 @@
         registry.unregisterUtility(event.object, ISiteManager, name=event.object.__name__)
 
 
+@adapter_config(context=ISiteManager, provides=IFormContextPermissionChecker)
+class SiteManagerPermissionChecker(ContextAdapter):
+    """Site manager edit permission checker"""
+
+    edit_permission = MANAGE_SITE_PERMISSION
+
+
 @vocabulary_config(name='PyAMS site managers')
 class SiteManagerVocabulary(SimpleVocabulary):
     """Site manager vocabulary"""
--- a/src/pyams_content/shared/site/zmi/__init__.py	Sun Nov 26 09:57:42 2017 +0100
+++ b/src/pyams_content/shared/site/zmi/__init__.py	Sun Nov 26 09:58:07 2017 +0100
@@ -14,7 +14,112 @@
 
 
 # import standard library
+from uuid import uuid4
 
 # import interfaces
+from pyams_content.interfaces import CREATE_CONTENT_PERMISSION
+from pyams_content.shared.common.interfaces import IWfSharedContent
+from pyams_content.shared.site.interfaces import ISiteContainer, ISiteManager
+from pyams_i18n.interfaces import II18nManager
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_workflow.interfaces import IWorkflowInfo, IWorkflowVersions
+from pyams_zmi.layer import IAdminLayer
+from zope.intid.interfaces import IIntIds
 
 # import packages
+from pyams_content.shared.common.zmi import SharedContentAddForm, SharedContentAJAXAddForm
+from pyams_content.shared.site.zmi.widget import SiteManagerFoldersSelectorFieldWidget
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_utils.registry import get_utility
+from pyams_utils.traversing import get_parent
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config
+from pyramid.view import view_config
+from z3c.form import field
+from zope.interface import Interface
+from zope.lifecycleevent import ObjectCreatedEvent
+from zope.schema import Int
+from pyams_content import _
+
+
+@viewlet_config(name='add-topic.menu', context=ISiteContainer, layer=IAdminLayer, view=Interface,
+                manager=IToolbarAddingMenu, permission=CREATE_CONTENT_PERMISSION, weight=20)
+class TopicAddMenu(ToolbarMenuItem):
+    """Topic add menu"""
+
+    label = _("Add topic...")
+    label_css_class = 'fa fa-fw fa-file-o'
+    url = 'add-topic.html'
+    modal_target = True
+
+
+class ITopicAddFormFields(IWfSharedContent):
+    """Topic add form fields interface"""
+
+    parent = Int(title=_("Parent"),
+                 description=_("Topic's parent"),
+                 required=True)
+
+
+@pagelet_config(name='add-topic.html', context=ISiteContainer, layer=IPyAMSLayer,
+                permission=CREATE_CONTENT_PERMISSION)
+@pagelet_config(name='add-shared-content.html', context=ISiteContainer, layer=IPyAMSLayer,
+                permission=CREATE_CONTENT_PERMISSION)
+class TopicAddForm(SharedContentAddForm):
+    """Topic add form"""
+
+    legend = _("Add topic")
+
+    fields = field.Fields(ITopicAddFormFields).select('title', 'parent', 'notepad')
+    fields['parent'].widgetFactory = SiteManagerFoldersSelectorFieldWidget
+
+    ajax_handler = 'add-topic.json'
+    edit_permission = CREATE_CONTENT_PERMISSION
+
+    __target = None
+
+    def updateWidgets(self, prefix=None):
+        super(TopicAddForm, self).updateWidgets(prefix)
+        if 'parent' in self.widgets:
+            self.widgets['parent'].permission = CREATE_CONTENT_PERMISSION
+
+    def create(self, data):
+        manager = get_parent(self.context, ISiteManager)
+        return manager.topic_content_factory.content_class()
+
+    def update_content(self, content, data):
+        # initialize content fields
+        content.title = data['title']
+        content.short_name = content.title.copy()
+        content.notepad = data.get('notepad')
+        content.creator = self.request.principal.id
+        content.owner = self.request.principal.id
+        # get parent
+        intids = get_utility(IIntIds)
+        parent = intids.queryObject(data.get('parent'))
+        if parent is not None:
+            languages = II18nManager(parent).languages
+            if languages:
+                II18nManager(content).languages = languages.copy()
+            manager = get_parent(parent, ISiteManager)
+            wf_parent = manager.topic_content_factory()
+            self.request.registry.notify(ObjectCreatedEvent(wf_parent))
+            uuid = str(uuid4())
+            parent[uuid] = wf_parent
+            IWorkflowVersions(wf_parent).add_version(content, None)
+            IWorkflowInfo(content).fire_transition('init', comment=content.notepad)
+            self.__target = content
+
+    def add(self, content):
+        pass
+
+    def nextURL(self):
+        return absolute_url(self.__target, self.request, 'admin')
+
+
+@view_config(name='add-topic.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=CREATE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class TopicAJAXAddForm(SharedContentAJAXAddForm, TopicAddForm):
+    """Topic add form, JSON renderer"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/zmi/container.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,535 @@
+#
+# 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 json
+
+# import interfaces
+from pyams_content.interfaces import MANAGE_SITE_PERMISSION
+from pyams_content.shared.common.interfaces import ISharedContent
+from pyams_content.shared.common.interfaces.zmi import IDashboardTable
+from pyams_content.shared.site.interfaces import ISiteContainer, ISiteManager
+from pyams_content.zmi.interfaces import IUserAddingsMenuLabel, ISiteTreeMenu, ISiteTreeTable
+from pyams_i18n.interfaces import II18n
+from pyams_sequence.interfaces import ISequentialIdInfo
+from pyams_skin.interfaces import IInnerPage, IPageHeader
+from pyams_skin.interfaces.container import ITableElementEditor, ITableElementName, ITableWithActions
+from pyams_skin.interfaces.viewlet import IBreadcrumbItem, ITableItemColumnActionsMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
+from pyams_workflow.interfaces import IWorkflowVersions, IWorkflowPublicationInfo, IWorkflowState, IWorkflow
+from pyams_zmi.interfaces.menu import ISiteManagementMenu, IPropertiesMenu
+from pyams_zmi.layer import IAdminLayer
+from z3c.table.interfaces import IColumn, IValues
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_content.shared.site import WfTopic
+from pyams_content.skin import pyams_content
+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 BaseTable, TrashColumn, DefaultElementEditorAdapter, NameColumn, SorterColumn, \
+    ActionColumn, I18nColumn
+from pyams_skin.viewlet.breadcrumb import BreadcrumbItem
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter, ContextRequestAdapter
+from pyams_utils.fanstatic import get_resource_path
+from pyams_utils.registry import get_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_zmi.form import AdminDialogEditForm
+from pyams_zmi.view import AdminView
+from pyramid.location import lineage
+from pyramid.view import view_config
+from z3c.form import field
+from z3c.table.column import GetAttrColumn
+from zope.interface import implementer
+
+from pyams_content import _
+
+
+@adapter_config(context=(ISiteContainer, IAdminLayer), provides=IBreadcrumbItem)
+class SiteContainerBreadcrumbAdapter(BreadcrumbItem):
+    """Site container breadcrumb adapter"""
+
+    @property
+    def label(self):
+        return II18n(self.context).query_attribute('short_name', request=self.request)
+
+
+@adapter_config(context=(ISiteContainer, IAdminLayer), provides=IUserAddingsMenuLabel)
+class SiteManagerUserAddingsMenuLabelAdapter(ContextRequestAdapter):
+    """Site container user addings menu label adapter"""
+
+    @property
+    def label(self):
+        return '{content} ({blog})'.format(
+            content=self.request.localizer.translate(WfTopic.content_name),
+            blog=II18n(self.context).query_attribute('title', request=self.request))
+
+
+#
+# Site container publication views
+#
+
+@viewlet_config(name='workflow-publication.menu', context=ISiteContainer, layer=IPyAMSLayer, view=ISiteTreeTable,
+                manager=ITableItemColumnActionsMenu, permission=MANAGE_SITE_PERMISSION, weight=2)
+class SiteContainerTableItemWorkflowPublicationMenu(ToolbarMenuItem):
+    """Site container tree item workflow publication menu"""
+
+    label = _("Publication dates...")
+    label_css_class = 'fa fa-fw fa-eye'
+    url = 'workflow-publication.html'
+    modal_target = True
+    stop_propagation = True
+
+
+@viewlet_config(name='workflow-publication.menu', context=ISiteContainer, layer=IAdminLayer, manager=IPropertiesMenu,
+                permission=MANAGE_SITE_PERMISSION, weight=2)
+class SiteContainerWorkflowPublicationMenu(MenuItem):
+    """Site container workflow publication menu"""
+
+    label = _("Publication dates...")
+    icon_class = 'fa-eye'
+    url = 'workflow-publication.html'
+    modal_target = True
+
+
+@pagelet_config(name='workflow-publication.html', context=ISiteContainer, layer=IPyAMSLayer,
+                permission=MANAGE_SITE_PERMISSION)
+class SiteContainerWorkflowPublicationEditForm(AdminDialogEditForm):
+    """Site container workflow publication edit form"""
+
+    legend = _("Update publication dates")
+
+    fields = field.Fields(IWorkflowPublicationInfo).select('publication_effective_date', 'publication_expiration_date')
+    ajax_handler = 'workflow-publication.json'
+    edit_permission = MANAGE_SITE_PERMISSION
+
+
+@view_config(name='workflow-publication.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_PERMISSION, renderer='json', xhr=True)
+class SiteContainerWorkflowPublicationAJAXEditForm(AJAXEditForm, SiteContainerWorkflowPublicationEditForm):
+    """Site container workflow publication edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = super(SiteContainerWorkflowPublicationAJAXEditForm, self).get_ajax_output(changes)
+        if changes:
+            info = IWorkflowPublicationInfo(self.context, None)
+            if info is not None:
+                if info.is_published():
+                    icon_class = 'fa-eye opacity-75'
+                else:
+                    icon_class = 'fa-eye-slash text-danger opaque'
+                value = '<i class="fa fa-fw {icon_class} hint align-base" title="{title}" ' \
+                        'data-ams-hint-gravity="e"></i>'.format(
+                            icon_class=icon_class,
+                            title=self.request.localizer.translate(_("Visible element?")))
+                intids = get_utility(IIntIds)
+                output.setdefault('events', []).append({
+                    'event': 'myams.refresh',
+                    'options': {
+                        'handler': 'MyAMS.skin.refreshRowCell',
+                        'object_id': '{0}::{1}'.format(SiteContainerTreeTable.id, intids.queryId(self.context)),
+                        'col_name': 'visible',
+                        'cell': value
+                    }
+                })
+        return output
+
+
+#
+# Site container tree view
+#
+
+@viewlet_config(name='site-tree.menu', layer=IAdminLayer, context=ISiteContainer, manager=ISiteManagementMenu,
+                permission=VIEW_SYSTEM_PERMISSION, weight=5)
+@viewletmanager_config(name='site-tree.menu', layer=IAdminLayer, context=ISiteContainer, provides=ISiteTreeMenu)
+@implementer(ISiteTreeMenu)
+class SiteContainerTreeMenu(MenuItem):
+    """Site container tree menu"""
+
+    label = _("Site tree")
+    icon_class = 'fa-sitemap'
+    url = '#site-tree.html'
+
+
+@implementer(IDashboardTable, ISiteTreeTable, ITableWithActions)
+class SiteContainerTreeTable(BaseTable):
+    """Site container tree table"""
+
+    id = 'site_tree_table'
+    title = _("Site tree")
+
+    hide_body_toolbar = True
+    sortOn = None
+
+    permission = MANAGE_SITE_PERMISSION
+
+    def __init__(self, context, request, can_sort=False, rows_state=None):
+        super(SiteContainerTreeTable, self).__init__(context, request)
+        self.can_sort = can_sort
+        self.rows_state = rows_state
+
+    @property
+    def cssClasses(self):
+        classes = ['table', 'table-bordered', 'table-striped', 'table-hover', 'table-tight']
+        permission = self.permission
+        if self.can_sort and ((not permission) or self.request.has_permission(permission, self.context)):
+            classes.append('table-dnd')
+        return {'table': ' '.join(classes)}
+
+    @property
+    def data_attributes(self):
+        attributes = super(SiteContainerTreeTable, self).data_attributes
+        intids = get_utility(IIntIds)
+        manager = get_parent(self.context, ISiteManager)
+        attributes.setdefault('table', {}).update({
+            'data-ams-plugins': 'pyams_content',
+            'data-ams-plugin-pyams_content-src': get_resource_path(pyams_content),
+            'data-ams-location': absolute_url(self.context, self.request),
+            'data-ams-datatable-sort': 'false',
+            'data-ams-datatable-pagination': 'false',
+            'data-ams-delete-target': 'delete-site-item.json',
+            'data-ams-tree-node-id': intids.queryId(manager),
+            'data-ams-tablednd-drag-handle': 'td.sorter',
+            'data-ams-tablednd-drop': 'MyAMS.tree.sortTree',
+            'data-ams-tablednd-drop-target': 'set-site-order.json'
+        })
+        attributes.setdefault('tr', {}).update({
+            'id': lambda x, col: '{0}::{1}'.format(self.id, intids.queryId(x)),
+            'data-ams-location': lambda x, col: absolute_url(x.__parent__, self.request),
+            'data-ams-tree-node-id': lambda x, col: intids.queryId(x),
+            'data-ams-tree-node-parent-id': lambda x, col: intids.queryId(x.__parent__)
+        })
+        return attributes
+
+
+@adapter_config(name='sorter', context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IColumn)
+class SiteContainerTreeSorterColumn(SorterColumn):
+    """Site container tree sorter column"""
+
+    permission = MANAGE_SITE_PERMISSION
+
+    def renderCell(self, item):
+        if self.table.can_sort:
+            return super(SiteContainerTreeSorterColumn, self).renderCell(item)
+        else:
+            return ''
+
+
+@adapter_config(name='visible', context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IColumn)
+class SiteContainerTreeVisibleColumn(ActionColumn):
+    """Site container tree visible column"""
+
+    cssClasses = {'th': 'action',
+                  'td': 'action'}
+
+    icon_class = 'fa fa-fw fa-eye'
+    icon_hint = _("Visible element?")
+
+    weight = 5
+
+    def renderCell(self, item):
+        return self.get_icon(item)
+
+    def get_icon(self, item):
+        if ISharedContent.providedBy(item):
+            item = IWorkflowVersions(item).get_last_versions(count=1)[-1]
+        info = IWorkflowPublicationInfo(item, None)
+        if info is None:
+            return ''
+        else:
+            if info.is_published():
+                icon_class = 'fa-eye opacity-75'
+            else:
+                icon_class = 'fa-eye-slash text-danger opaque'
+            return '<i class="fa fa-fw {icon_class} hint align-base" title="{title}" data-ams-hint-gravity="e"></i>'.format(
+                icon_class=icon_class,
+                title=self.request.localizer.translate(self.icon_hint))
+
+
+@adapter_config(name='name', context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IColumn)
+class SiteContainerTreeNameColumn(NameColumn):
+    """Site container tree name column"""
+
+    _header = _("Folders and topics")
+
+    def renderHeadCell(self):
+        return '<span data-ams-stop-propagation="true"' \
+               '      data-ams-click-handler="MyAMS.tree.switchTree">' \
+               '    <span class="small hint" title="{hint}" data-ams-hint-gravity="e">' \
+               '        <i class="fa fa-fw fa-plus-square-o switch"></i>' \
+               '    </span>&nbsp;&nbsp;{title}' \
+               '</span>'.format(
+                    hint=self.request.localizer.translate(_("Click to open/close all folders")),
+                    title=super(SiteContainerTreeNameColumn, self).renderHeadCell())
+
+    def renderCell(self, item, name=None):
+        depth = -3
+        for node in lineage(item):
+            depth += 1
+        return '<div class="name">' \
+               '    {padding}' \
+               '    <span class="small hint" title="{hint}" data-ams-hint-gravity="e" ' \
+               '          data-ams-click-handler="MyAMS.tree.switchTableNode"' \
+               '          data-ams-stop-propagation="true">' \
+               '        <i class="fa fa-fw {switch}"></i>' \
+               '    </span>&nbsp;&nbsp;<span class="title">{title}</span>' \
+               '</div>'.format(
+                   padding='<span class="tree-node-padding"></span>' * depth,
+                   hint=self.request.localizer.translate(_("Click to show/hide inner folders")),
+                   switch='fa-{state}-square-o switch'.format(
+                       state=self.table.rows_state or ('minus' if item in lineage(self.context) else 'plus'))
+                           if ISiteContainer.providedBy(item) else '',
+                   title=name or super(SiteContainerTreeNameColumn, self).renderCell(item))
+
+
+@adapter_config(name='oid', context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IColumn)
+class SiteContainerTreeOidColumn(I18nColumn, GetAttrColumn):
+    """Site container tree OID column"""
+
+    _header = _("OID")
+    weight = 70
+
+    def getValue(self, obj):
+        sequence = ISequentialIdInfo(obj, None)
+        if sequence is None:
+            return '--'
+        else:
+            try:
+                return sequence.get_short_oid()
+            except TypeError:
+                return '--'
+
+
+@adapter_config(name='state', context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IColumn)
+class SiteContainerTreeStateColumn(I18nColumn, GetAttrColumn):
+    """Site container tree state column"""
+
+    _header = _("Status")
+    weight = 80
+
+    def getValue(self, obj):
+        if not ISharedContent.providedBy(obj):
+            return '--'
+        version = IWorkflowVersions(obj).get_last_versions()[-1]
+        return self.request.localizer.translate(IWorkflow(version).get_state_label(IWorkflowState(version).state))
+
+
+@adapter_config(name='version', context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IColumn)
+class SiteContainerTreeVersionColumn(I18nColumn, GetAttrColumn):
+    """Site container tree version column"""
+
+    _header = _("Version")
+    weight = 90
+
+    def getValue(self, obj):
+        if not ISharedContent.providedBy(obj):
+            return '--'
+        version = IWorkflowVersions(obj).get_last_versions()[-1]
+        return IWorkflowState(version).version_id
+
+
+@adapter_config(name='trash', context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IColumn)
+class SiteContainerTreeTrashColumn(TrashColumn):
+    """Site container tree trash column"""
+
+    icon_hint = _("Delete site item")
+    permission = MANAGE_SITE_PERMISSION
+
+    def has_permission(self, item):
+        if (not ISiteContainer.providedBy(item)) or (item in lineage(self.context)):
+            return False
+        return super(SiteContainerTreeTrashColumn, self).has_permission(item)
+
+
+@adapter_config(context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=IValues)
+class SiteContainerTreeValuesAdapter(ContextRequestViewAdapter):
+    """Site container tree values adapter"""
+
+    @property
+    def values(self):
+
+        def get_values(container, result):
+            if container not in result:
+                result.append(container)
+            if ISiteContainer.providedBy(container) and (container in lineage(self.context)):
+                for child in container.values():
+                    get_values(child, result)
+            return result
+
+        manager = get_parent(self.context, ISiteManager)
+        values = []
+        for container in manager.values():
+            values.append(container)
+            if ISiteContainer.providedBy(container):
+                get_values(container, values)
+        return values
+
+
+@pagelet_config(name='site-tree.html', context=ISiteContainer, layer=IPyAMSLayer, permission=VIEW_SYSTEM_PERMISSION)
+@implementer(IInnerPage)
+class SiteContainerTreeView(AdminView, ContainerView):
+    """Site Container tree view"""
+
+    table_class = SiteContainerTreeTable
+
+    def __init__(self, context, request):
+        super(ContainerView, self).__init__(context, request)
+        self.table = SiteContainerTreeTable(context, request, can_sort=ISiteManager.providedBy(context))
+
+
+@adapter_config(context=(ISiteContainer, IAdminLayer, ISiteTreeTable), provides=IPageHeader)
+class SiteContainerViewHeaderAdapter(DefaultPageHeaderAdapter):
+    """Site container tree view header adapter"""
+
+    icon_class = 'fa fa-fw fa-sitemap'
+
+
+@view_config(name='get-tree.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=VIEW_SYSTEM_PERMISSION, renderer='json', xhr=True)
+def get_tree(request):
+    """Get whole tree"""
+
+    def get_tree_values(parent):
+        """Iterator over container tree items"""
+        for item in parent.values():
+            yield item
+            if ISiteContainer.providedBy(item):
+                yield from get_tree_values(item)
+
+    table = SiteContainerTreeTable(request.context, request,
+                                   can_sort=json.loads(request.params.get('can_sort', 'false')))
+    table.update()
+    result = []
+    manager = get_parent(request.context, ISiteManager)
+    for item in get_tree_values(manager):
+        row = table.setUpRow(item)
+        result.append(table.renderRow(row).strip())
+    return result
+
+
+@view_config(name='get-tree-nodes.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=VIEW_SYSTEM_PERMISSION, renderer='json', xhr=True)
+def get_tree_nodes(request):
+    """Get tree nodes"""
+    table = SiteContainerTreeTable(request.context, request,
+                                   can_sort=json.loads(request.params.get('can_sort', 'false')))
+    table.update()
+    result = []
+    for item in request.context.values():
+        row = table.setUpRow(item)
+        result.append(table.renderRow(row).strip())
+    return result
+
+
+@view_config(name='set-site-order.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_PERMISSION, renderer='json', xhr=True)
+def set_site_order(request):
+    """Set site elements order"""
+    intids = get_utility(IIntIds)
+    parent = intids.queryObject(int(request.params.get('parent')))
+    # check for changing parent
+    if request.params.get('action') == 'reparent':
+        child = intids.queryObject(int(request.params.get('child')))
+        old_parent = child.__parent__
+        new_name = old_name = child.__name__
+        if old_name in parent:
+            index = 1
+            new_name = '{name}-{index:02}'.format(name=old_name, index=index)
+            while new_name in parent:
+                index += 1
+                new_name = '{name}-{index:02}'.format(name=old_name, index=index)
+        parent[new_name] = child
+        del old_parent[old_name]
+    # Re-define order
+    names = [child.__name__ for child in [intids.queryObject(oid)
+                                          for oid in map(int, json.loads(request.params.get('order')))]
+             if child.__parent__ is parent]
+    parent.updateOrder(names)
+    # get all new parent child
+    table = SiteContainerTreeTable(request.context, request,
+                                   can_sort=json.loads(request.params.get('can_sort', 'false')),
+                                   rows_state='plus')
+    table.update()
+    result = []
+    for item in parent.values():
+        row = table.setUpRow(item)
+        result.append(table.renderRow(row).strip())
+    return result
+    
+
+@view_config(name='delete-site-item.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_PERMISSION, renderer='json', xhr=True)
+def delete_site_item(request):
+    """Delete item from site container"""
+    translate = request.localizer.translate
+    name = request.params.get('object_name')
+    if not name:
+        return {'status': 'message',
+                'messagebox': {'status': 'error',
+                               'content': translate(_("No provided object_name argument!"))}}
+    if name not in request.context:
+        return {'status': 'message',
+                'messagebox': {'status': 'error',
+                               'content': translate(_("Given object name doesn't exist!"))}}
+    del request.context[name]
+    return {'status': 'success'}
+
+
+@adapter_config(context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=ITableElementName)
+class SiteContainerTableElementName(ContextRequestViewAdapter):
+    """Site container tree table element name"""
+
+    @property
+    def name(self):
+        return II18n(self.context).query_attribute('title', request=self.request)
+
+
+@adapter_config(context=(ISharedContent, IPyAMSLayer, ISiteTreeTable), provides=ITableElementName)
+class SharedContentTableElementName(ContextRequestViewAdapter):
+    """Shared content tree table element name"""
+
+    @property
+    def name(self):
+        version = IWorkflowVersions(self.context).get_last_versions(count=1)[0]
+        return II18n(version).query_attribute('title', request=self.request)
+
+
+@adapter_config(context=(ISiteContainer, IPyAMSLayer, ISiteTreeTable), provides=ITableElementEditor)
+class SiteContainerTableElementEditor(DefaultElementEditorAdapter):
+    """Site container tree table element editor"""
+
+    view_name = 'admin#site-tree.html'
+    modal_target = False
+
+
+@adapter_config(context=(ISharedContent, IPyAMSLayer, ISiteTreeTable), provides=ITableElementEditor)
+class SharedContentTableElementEditor(DefaultElementEditorAdapter):
+    """Shared content tree table element editor"""
+
+    view_name = 'admin'
+    modal_target = False
+
+    @property
+    def url(self):
+        version = IWorkflowVersions(self.context).get_last_versions(count=1)[0]
+        return absolute_url(version, self.request, self.view_name)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/zmi/folder.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,146 @@
+#
+# 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_content.interfaces import MANAGE_SITE_PERMISSION
+from pyams_content.shared.site.interfaces import ISiteContainer, ISiteManager
+from pyams_i18n.interfaces import INegotiator, II18n
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.layer import IAdminLayer
+from z3c.form.interfaces import IDataExtractedEvent
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_content.shared.site.zmi.widget import SiteManagerFoldersSelectorFieldWidget
+from pyams_form.form import AJAXAddForm
+from pyams_i18n.schema import I18nTextLineField
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_utils.registry import get_utility
+from pyams_utils.traversing import get_parent
+from pyams_utils.unicode import translate_string
+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.schema import Text, Int
+
+from pyams_content import _
+
+
+@viewlet_config(name='add-site-folder.menu', context=ISiteContainer, layer=IAdminLayer, view=Interface,
+                manager=IToolbarAddingMenu, permission=MANAGE_SITE_PERMISSION, weight=10)
+class SiteFolderAddMenu(ToolbarMenuItem):
+    """Site folder add menu"""
+
+    label = _("Add site folder...")
+    label_css_class = 'fa fa-fw fa-folder-o'
+    url = 'add-site-folder.html'
+    modal_target = True
+
+
+class ISiteFolderAddFormFields(Interface):
+    """Site folder add form fields interface"""
+
+    title = I18nTextLineField(title=_("Title"),
+                              description=_("Visible label used to display content"),
+                              required=True)
+
+    parent = Int(title=_("Parent"),
+                 description=_("Folder's parent"),
+                 required=True)
+
+    notepad = Text(title=_("Notepad"),
+                   description=_("Internal information to be known about this content"),
+                   required=False)
+
+
+@pagelet_config(name='add-site-folder.html', context=ISiteContainer, layer=IPyAMSLayer,
+                permission=MANAGE_SITE_PERMISSION)
+class SiteFolderAddForm(AdminDialogAddForm):
+    """Site folder add form"""
+
+    @property
+    def title(self):
+        return II18n(self.context).query_attribute('title', request=self.request)
+
+    legend = _("Add site folder")
+    icon_css_class = 'fa fa-fw fa-folder-o'
+
+    fields = field.Fields(ISiteFolderAddFormFields)
+    fields['parent'].widgetFactory = SiteManagerFoldersSelectorFieldWidget
+
+    ajax_handler = 'add-site-folder.json'
+    edit_permission = MANAGE_SITE_PERMISSION
+
+    def updateWidgets(self, prefix=None):
+        super(SiteFolderAddForm, self).updateWidgets(prefix)
+        if 'parent' in self.widgets:
+            self.widgets['parent'].permission = MANAGE_SITE_PERMISSION
+        if 'notepad' in self.widgets:
+            self.widgets['notepad'].widget_css_class = 'textarea'
+
+    def create(self, data):
+        manager = get_parent(self.context, ISiteManager)
+        return manager.folder_factory()
+
+    def update_content(self, content, data):
+        content.title = data['title']
+        content.short_name = data['title']
+        content.notepad = data['notepad']
+        intids = get_utility(IIntIds)
+        parent = intids.queryObject(data.get('parent'))
+        if parent is not None:
+            negotiator = get_utility(INegotiator)
+            title = II18n(content).get_attribute('title', lang=negotiator.server_language)
+            name = translate_string(title, force_lower=True, spaces='-')
+            if name in parent:
+                index = 1
+                new_name = '{name}-{index:02}'.format(name=name, index=index)
+                while new_name in parent:
+                    index += 1
+                    new_name = '{name}-{index:02}'.format(name=name, index=index)
+                name = new_name
+            parent[name] = content
+
+    def add(self, content):
+        pass
+
+    def nextURL(self):
+        return absolute_url(self.context, self.request, 'admin#site-tree.html')
+
+
+@subscriber(IDataExtractedEvent, form_selector=SiteFolderAddForm)
+def handle_site_folder_add_form_data_extraction(event):
+    """Handle site folder add form data extraction"""
+    negotiator = get_utility(INegotiator)
+    title = event.data.get('title', {}).get(negotiator.server_language)
+    if not title:
+        event.form.widgets.errors += (Invalid(_("You must provide a folder name for default server language!")),)
+
+
+@view_config(name='add-site-folder.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_PERMISSION, renderer='json', xhr=True)
+class SiteFolderAJAXAddForm(AJAXAddForm, SiteFolderAddForm):
+    """Site folder add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        return {'status': 'reload'}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/zmi/link.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,167 @@
+#
+# 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.location import lineage
+
+from pyams_content.shared.site.zmi.container import SiteContainerTreeTable, SiteContainerTreeNameColumn
+from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+from uuid import uuid4
+
+# import interfaces
+from pyams_content.interfaces import CREATE_CONTENT_PERMISSION, MANAGE_CONTENT_PERMISSION
+from pyams_content.shared.site.interfaces import ISiteContainer, IContentLink
+from pyams_content.zmi.interfaces import ISiteTreeTable
+from pyams_i18n.interfaces import II18n
+from pyams_skin.interfaces.container import ITableElementName
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.layer import IAdminLayer
+from zope.intid.interfaces import IIntIds
+
+# import packages
+from pyams_content.shared.site.link import ContentLink
+from pyams_content.shared.site.zmi.widget import SiteManagerFoldersSelectorFieldWidget
+from pyams_form.form import AJAXAddForm, AJAXEditForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.table import get_object_name
+from pyams_skin.viewlet.toolbar import ToolbarMenuItem
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
+from pyams_utils.registry import get_utility
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyramid.view import view_config
+from z3c.form import field
+from zope.interface import Interface
+from zope.schema import Int
+
+from pyams_content import _
+
+
+@viewlet_config(name='add-content-link.menu', context=ISiteContainer, layer=IAdminLayer, view=Interface,
+                manager=IToolbarAddingMenu, permission=CREATE_CONTENT_PERMISSION, weight=90)
+class ContentLinkAddMenu(ToolbarMenuItem):
+    """Content link add menu"""
+
+    label = _("Rent content...")
+    label_css_class = 'fa fa-fw fa-external-link-square fa-rotate-90'
+    url = 'add-content-link.html'
+    modal_target = True
+
+
+class IContentLinkAddFormFields(IContentLink):
+    """Content link add forms fields interface"""
+
+    parent = Int(title=_("Parent"),
+                 description=_("Folder's parent"),
+                 required=True)
+
+
+@pagelet_config(name='add-content-link.html', context=ISiteContainer, layer=IPyAMSLayer,
+                permission=CREATE_CONTENT_PERMISSION)
+class ContentLinkAddForm(AdminDialogAddForm):
+    """Content link add form"""
+
+    legend = _("Rent existing content")
+
+    fields = field.Fields(IContentLinkAddFormFields).select('reference', 'alt_title', 'parent')
+    fields['parent'].widgetFactory = SiteManagerFoldersSelectorFieldWidget
+
+    ajax_handler = 'add-content-link.json'
+    edit_permission = CREATE_CONTENT_PERMISSION
+
+    __target = None
+
+    def updateWidgets(self, prefix=None):
+        super(ContentLinkAddForm, self).updateWidgets(prefix)
+        if 'parent' in self.widgets:
+            self.widgets['parent'].permission = CREATE_CONTENT_PERMISSION
+
+    def create(self, data):
+        return ContentLink()
+
+    def update_content(self, content, data):
+        content.reference = data.get('reference')
+        content.alt_title = data['alt_title']
+        intids = get_utility(IIntIds)
+        parent = intids.queryObject(data.get('parent'))
+        if parent is not None:
+            uuid = str(uuid4())
+            parent[uuid] = content
+            self.__target = parent
+
+    def add(self, content):
+        pass
+
+    def nextURL(self):
+        return absolute_url(self.__target, self.request, 'admin#site-tree.html')
+
+
+@view_config(name='add-content-link.json', context=ISiteContainer, request_type=IPyAMSLayer,
+             permission=CREATE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ContentLinkAJAXAddForm(AJAXAddForm, ContentLinkAddForm):
+    """Content link add form, JSOn renderer"""
+
+
+@adapter_config(context=(IContentLink, IPyAMSLayer, ISiteTreeTable), provides=ITableElementName)
+class ContentLinkTableElementName(ContextRequestViewAdapter):
+    """Content link table element name"""
+
+    @property
+    def name(self):
+        title = II18n(self.context).query_attribute('alt_title', request=self.request)
+        if not title:
+            target = self.context.get_target()
+            if target is not None:
+                title = get_object_name(target, self.request, self.view)
+        return '<i class="fa fa-fw fa-external-link-square rotate-90"></i>{title}'.format(
+            title=title or '--')
+
+
+@pagelet_config(name='properties.html', context=IContentLink, layer=IPyAMSLayer,
+                permission=VIEW_SYSTEM_PERMISSION)
+class ContentLinkPropertiesEditForm(AdminDialogEditForm):
+    """Content link properties edit form"""
+
+    legend = _("Edit content link properties")
+
+    fields = field.Fields(IContentLink).omit('__parent__', '__name__')
+
+    ajax_handler = 'properties.json'
+    edit_permission = MANAGE_CONTENT_PERMISSION
+
+
+@view_config(name='properties.json', context=IContentLink, request_type=IPyAMSLayer,
+             permission=MANAGE_CONTENT_PERMISSION, renderer='json', xhr=True)
+class ContentLinkPropertiesAJAXEditForm(AJAXEditForm, ContentLinkPropertiesEditForm):
+    """Content link properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = super(ContentLinkPropertiesAJAXEditForm, self).get_ajax_output(changes)
+        if 'alt_title' in changes.get(IContentLink, ()):
+            intids = get_utility(IIntIds)
+            adapter = ContentLinkTableElementName(self.context, self.request, None)
+            column = SiteContainerTreeNameColumn(self.context, self.request, None)
+            output.setdefault('events', []).append({
+                'event': 'myams.refresh',
+                'options': {
+                    'handler': 'MyAMS.skin.refreshRowCell',
+                    'object_id': '{0}::{1}'.format(SiteContainerTreeTable.id, intids.queryId(self.context)),
+                    'col_name': 'name',
+                    'cell': column.renderCell(self.context, name=adapter.name)
+                }
+            })
+        return output
--- a/src/pyams_content/shared/site/zmi/manager.py	Sun Nov 26 09:57:42 2017 +0100
+++ b/src/pyams_content/shared/site/zmi/manager.py	Sun Nov 26 09:58:07 2017 +0100
@@ -19,20 +19,20 @@
 from pyams_content.interfaces import MANAGE_SITE_ROOT_PERMISSION
 from pyams_content.root.interfaces import ISiteRoot
 from pyams_content.shared.site.interfaces import ISiteManager
-from pyams_content.zmi.interfaces import IUserAddingsMenuLabel
+from pyams_content.zmi.interfaces import IUserAddingsMenuLabel, ISiteTreeTable
 from pyams_i18n.interfaces import II18n, INegotiator
 from pyams_skin.interfaces.container import ITableElementEditor
-from pyams_skin.interfaces.viewlet import IToolbarAddingMenu
+from pyams_skin.interfaces.viewlet import IToolbarAddingMenu, IBreadcrumbItem
 from pyams_skin.layer import IPyAMSLayer
 from pyams_zmi.layer import IAdminLayer
 from z3c.form.interfaces import IDataExtractedEvent
 
 # import packages
-from pyams_content.shared.site.manager import SiteManager
-from pyams_content.shared.zmi.sites import SiteTreeTable
+from pyams_content.shared.site import WfTopic
 from pyams_form.form import AJAXAddForm
 from pyams_pagelet.pagelet import pagelet_config
 from pyams_skin.table import DefaultElementEditorAdapter
+from pyams_skin.viewlet.breadcrumb import BreadcrumbItem
 from pyams_skin.viewlet.toolbar import ToolbarMenuItem
 from pyams_utils.adapter import adapter_config, ContextRequestAdapter
 from pyams_utils.registry import query_utility
@@ -41,6 +41,7 @@
 from pyams_viewlet.viewlet import viewlet_config
 from pyams_zmi.form import AdminDialogAddForm
 from pyramid.events import subscriber
+from pyramid.path import DottedNameResolver
 from pyramid.view import view_config
 from z3c.form import field
 from zope.interface import Invalid
@@ -48,19 +49,30 @@
 from pyams_content import _
 
 
+@adapter_config(context=(ISiteManager, IPyAMSLayer), provides=IBreadcrumbItem)
+class SiteManagerBreadcrumbAdapter(BreadcrumbItem):
+    """Site manager breadcrumb adapter"""
+
+    @property
+    def label(self):
+        return II18n(self.context).query_attribute('short_name', request=self.request)
+
+    css_class = 'strong'
+
+
 @adapter_config(context=(ISiteManager, IAdminLayer), provides=IUserAddingsMenuLabel)
-class SiteManageruserAddingsMenuLabelAdapter(ContextRequestAdapter):
+class SiteManagerUserAddingsMenuLabelAdapter(ContextRequestAdapter):
     """Site manager user addings menu label adapter"""
 
     @property
     def label(self):
         return '{content} ({blog})'.format(
-            content=self.request.localizer.translate(self.context.shared_content_factory.content_class.content_name),
+            content=self.request.localizer.translate(WfTopic.content_name),
             blog=II18n(self.context).query_attribute('title', request=self.request))
 
 
 @viewlet_config(name='add-site-manager.menu', context=ISiteRoot, layer=IAdminLayer,
-                view=SiteTreeTable, manager=IToolbarAddingMenu, permission=MANAGE_SITE_ROOT_PERMISSION)
+                view=ISiteTreeTable, manager=IToolbarAddingMenu, permission=MANAGE_SITE_ROOT_PERMISSION)
 class SiteManagerAddMenu(ToolbarMenuItem):
     """Site manager add menu"""
 
@@ -84,7 +96,11 @@
     edit_permission = None
 
     def create(self, data):
-        return SiteManager()
+        factory = self.request.registry.settings.get('pyams_content.config.site_factory')
+        if factory is None:
+            factory = 'pyams_content.shared.site.manager.SiteManager'
+        factory = DottedNameResolver().resolve(factory)
+        return factory()
 
     def add(self, object):
         short_name = II18n(object).query_attribute('short_name', request=self.request)
@@ -100,7 +116,7 @@
     """Handle new site manager data extraction"""
     container = event.form.context
     negotiator = query_utility(INegotiator)
-    short_name = event.data['short_name'].get(negotiator.server_language)
+    short_name = event.data.get('short_name', {}).get(negotiator.server_language)
     if not short_name:
         event.form.widgets.errors += (Invalid(_("You must provide a short name for default server language!")),)
         return
@@ -119,9 +135,9 @@
     """Site manager add form, JSOn renderer"""
 
 
-@adapter_config(context=(ISiteManager, IAdminLayer, SiteTreeTable), provides=ITableElementEditor)
+@adapter_config(context=(ISiteManager, IAdminLayer, ISiteTreeTable), provides=ITableElementEditor)
 class SiteManagerTableElementEditor(DefaultElementEditorAdapter):
     """Site tree table element editor"""
 
-    view_name = 'admin'
+    view_name = 'admin#site-tree.html'
     modal_target = False
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/zmi/widget/__init__.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,39 @@
+#
+# 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_content.shared.site.zmi.widget.interfaces import ISiteManagerFoldersSelectorWidget
+from pyams_skin.layer import IPyAMSLayer
+
+# import packages
+from pyams_form.widget import widgettemplate_config
+from z3c.form.browser.text import TextWidget
+from z3c.form.widget import FieldWidget
+from zope.interface import implementer_only
+
+
+@widgettemplate_config(mode='input', template='templates/folders-input.pt', layer=IPyAMSLayer)
+@implementer_only(ISiteManagerFoldersSelectorWidget)
+class SiteManagerFoldersSelectorWidget(TextWidget):
+    """Site manager folders selector widget"""
+
+    permission = None
+
+
+def SiteManagerFoldersSelectorFieldWidget(field, request):
+    """IFieldWidget factory for TextWidget."""
+    return FieldWidget(field, SiteManagerFoldersSelectorWidget(request))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/zmi/widget/interfaces.py	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,28 @@
+#
+# 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 z3c.form.interfaces import ITextWidget
+
+# import packages
+from zope.interface import Attribute
+
+
+class ISiteManagerFoldersSelectorWidget(ITextWidget):
+    """Site manager folders selector widget interface"""
+
+    permission = Attribute("Permission required to select a given node")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/shared/site/zmi/widget/templates/folders-input.pt	Sun Nov 26 09:58:07 2017 +0100
@@ -0,0 +1,23 @@
+<div i18n:domain="pyams_content"
+	 data-ams-plugins="pyams_content"
+	 tal:attributes="data-ams-plugin-pyams_content-src extension:resource_path('pyams_content.skin:pyams_content')">
+	<input type="hidden"
+			tal:attributes="id view/id;
+							name view/name;
+							class view/klass;
+							lang view/lang;
+							value view/value;
+							disabled view/disabled;
+							onchange view/onchange;
+							readonly view/readonly;
+							size view/size;
+							maxlength view/maxlength;" />
+	<div class="treeview bordered padding-5"
+		 tal:attributes="id string:${view/id}_treeview;
+						 data-ams-treeview-data python:context.get_folders_tree(permission=view.permission);"
+		 data-ams-treeview-show-border="false"
+		 data-ams-treeview-levels="3"
+		 data-ams-treeview-toggle-unselectable="false"
+		 data-ams-treeview-node-selected="PyAMS_content.widget.treeview.selectFolder"
+		 data-ams-treeview-node-unselected="PyAMS_content.widget.treeview.unselectFolder"></div>
+</div>