merge default doc-dc
authorDamien Correia
Wed, 13 Jun 2018 11:39:37 +0200
branchdoc-dc
changeset 664 aff026ee8508
parent 663 19d5d65babb4 (current diff)
parent 639 5e37429b7de2 (diff)
child 665 78a965e4fbb7
merge default
--- a/src/pyams_content/features/footer/zmi/__init__.py	Wed Jun 13 10:02:53 2018 +0200
+++ b/src/pyams_content/features/footer/zmi/__init__.py	Wed Jun 13 11:39:37 2018 +0200
@@ -132,6 +132,8 @@
     fields = field.Fields(IFooterSettings).select('renderer')
     weight = 1
 
+    _changes = None
+
     def __init__(self, context, request, group):
         context = IFooterSettings(context)
         super(FooterSettingsRendererEditSubform, self).__init__(context, request, group)
@@ -152,13 +154,24 @@
             alsoProvides(widget, IObjectData)
 
     def get_forms(self, include_self=True):
-        if include_self and self.request.method == 'POST':
+        if include_self and (self._changes is None) and (self.request.method == 'POST'):
             data, errors = self.extractData()
             if not errors:
-                self.applyChanges(data)
+                self._changes = self.applyChanges(data)
         for form in super(FooterSettingsRendererEditSubform, self).get_forms(include_self):
             yield form
 
+    def get_ajax_output(self, changes):
+        if not changes:
+            changes = self._changes
+        if changes:
+            return {
+                'status': 'success',
+                'message': self.request.localizer.translate(self.successMessage)
+            }
+        else:
+            return super(FooterSettingsRendererEditSubform, self).get_ajax_output(changes)
+
 
 @adapter_config(name='footer-renderer-settings-form',
                 context=(IFooterRendererSettings, IPyAMSLayer, FooterSettingsRendererEditSubform),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/menu/__init__.py	Wed Jun 13 11:39:37 2018 +0200
@@ -0,0 +1,44 @@
+#
+# Copyright (c) 2008-2018 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.features.menu.interfaces import IMenu, IMenusContainer, IMenuLink
+
+# import packages
+from pyams_content.component.association.container import AssociationContainer
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+
+
+#
+# Menus classes
+#
+
+@implementer(IMenu)
+class Menu(AssociationContainer):
+    """Associations menu"""
+
+    visible = FieldProperty(IMenu['visible'])
+    title = FieldProperty(IMenu['title'])
+
+
+@implementer(IMenusContainer)
+class MenusContainer(AssociationContainer):
+    """Associations menus container"""
+
+    def get_visible_items(self):
+        return filter(lambda x: IMenu(x).visible, self.values())
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/menu/interfaces/__init__.py	Wed Jun 13 11:39:37 2018 +0200
@@ -0,0 +1,75 @@
+#
+# Copyright (c) 2008-2018 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.component.association.interfaces import IAssociationContainer, IAssociationContainerTarget
+from zope.annotation.interfaces import IAttributeAnnotatable
+
+# import packages
+from pyams_i18n.schema import I18nTextLineField
+from zope.container.constraints import containers, contains
+from zope.interface import Interface
+from zope.schema import Bool
+
+from pyams_content import _
+
+
+class IMenuLink(Interface):
+    """Menu link marker interface"""
+
+
+class IMenuInternalLink(IMenuLink):
+    """Menu internal link marker interface"""
+
+
+class IMenuExternalLink(IMenuLink):
+    """Menu external link marker interface"""
+
+
+class IMenuLinksContainer(IAssociationContainer):
+    """Menu links container interface"""
+
+    contains(IMenuLink)
+
+
+class IMenuLinksContainerTarget(IAssociationContainerTarget):
+    """Menu links container marker interface"""
+
+
+class IMenu(IMenuLinksContainer):
+    """Menu container interface"""
+
+    containers('.IMenusContainer')
+
+    visible = Bool(title=_("Visible?"),
+                   description=_("Is this item visible in front-office?"),
+                   required=True,
+                   default=True)
+
+    title = I18nTextLineField(title=_("Menu title"),
+                              description=_("Displayed menu label"),
+                              required=True)
+
+
+class IMenusContainer(IAssociationContainer):
+    """Menus container interface"""
+
+    contains(IMenu)
+
+
+class IMenusContainerTarget(IAttributeAnnotatable):
+    """Menus container target marker interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/menu/zmi/__init__.py	Wed Jun 13 11:39:37 2018 +0200
@@ -0,0 +1,495 @@
+#
+# Copyright (c) 2008-2018 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.component.links.zmi import InternalLinkAddMenu, InternalLinkAddForm, InternalLinkPropertiesEditForm, \
+    ExternalLinkAddMenu, ExternalLinkAddForm, ExternalLinkPropertiesEditForm
+from pyams_content.features.menu import IMenusContainer, IMenu, Menu, IMenuLink
+from pyams_content.features.menu.interfaces import IMenusContainerTarget, IMenuLinksContainer, IMenuInternalLink, \
+    IMenuExternalLink, IMenuLinksContainerTarget
+from pyams_form.interfaces.form import IFormContextPermissionChecker
+from pyams_portal.interfaces import MANAGE_TEMPLATE_PERMISSION
+from pyams_skin.interfaces.viewlet import IWidgetTitleViewletManager, IToolbarAddingMenu
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.interfaces import VIEW_SYSTEM_PERMISSION
+from pyams_viewlet.interfaces import IViewletManager
+from z3c.table.interfaces import IValues, IColumn
+
+# import packages
+from pyams_content.component.association.zmi import AssociationsTable, AssociationsTablePublicNameColumn
+from pyams_form.form import ajax_config, AJAXAddForm, AJAXEditForm
+from pyams_i18n.column import I18nAttrColumn
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.container import switch_element_visibility, delete_container_element
+from pyams_skin.event import get_json_switched_table_refresh_event, get_json_table_row_refresh_event
+from pyams_skin.table import BaseTable, SorterColumn, VisibilitySwitcherColumn, I18nColumn, TrashColumn, NameColumn, \
+    get_table_id
+from pyams_skin.viewlet.toolbar import ToolbarAction
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter, ContextAdapter
+from pyams_utils.traversing import get_parent
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config
+from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyams_zmi.zmi.table import InnerTableView
+from pyramid.decorator import reify
+from pyramid.exceptions import NotFound
+from pyramid.view import view_config
+from z3c.form import field
+from zope.interface import implementer, alsoProvides, Interface
+
+from pyams_content import _
+
+
+#
+# Custom marker interfaces
+#
+
+class IMenuLinksView(Interface):
+    """Menu links view marker interface"""
+
+
+class IMenusView(Interface):
+    """Menus view marker interface"""
+
+
+#
+# Menus add and edit forms
+#
+
+@viewlet_config(name='add-menu.action', context=IMenusContainerTarget, layer=IPyAMSLayer,
+                view=IMenusView, manager=IWidgetTitleViewletManager, weight=10)
+class MenuAddAction(ToolbarAction):
+    """Menu add action"""
+
+    label = _("Add menu...")
+    url = 'add-menu.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-menu.html', context=IMenusContainer, layer=IPyAMSLayer,
+                permission=MANAGE_TEMPLATE_PERMISSION)
+@ajax_config(name='add-menu.json', context=IMenusContainer, layer=IPyAMSLayer, base=AJAXAddForm)
+class MenuAddForm(AdminDialogAddForm):
+    """Menu add form"""
+
+    legend = _("Add new menu")
+    icon_css_class = 'fa fa-fw fa-bars'
+
+    fields = field.Fields(IMenu).select('title')
+    edit_permission = MANAGE_TEMPLATE_PERMISSION
+
+    def create(self, data):
+        return Menu()
+
+    def add(self, object):
+        self.context.append(object)
+
+    def get_ajax_output(self, changes):
+        settings = get_parent(self.context, IMenusContainerTarget)
+        view = self.request.registry.getMultiAdapter((self.context, self.request), IMenusView,
+                                                     name=self.context.__name__)
+        return {
+            'status': 'success',
+            'message': self.request.localizer.translate(_("Menu was correctly added.")),
+            'events': [
+                get_json_switched_table_refresh_event(settings, self.request, view.table_class)
+            ]
+        }
+
+
+@pagelet_config(name='properties.html', context=IMenu, layer=IPyAMSLayer, permission=MANAGE_TEMPLATE_PERMISSION)
+@ajax_config(name='properties.json', context=IMenu, layer=IPyAMSLayer)
+class MenuPropertiesEditForm(AdminDialogEditForm):
+    """Menu properties edit form"""
+
+    legend = _("Edit menu properties")
+    icon_css_class = 'fa fa-fw fa-bars'
+
+    fields = field.Fields(IMenu).select('title')
+    edit_permission = MANAGE_TEMPLATE_PERMISSION
+
+    def get_ajax_output(self, changes):
+        output = super(self.__class__, self).get_ajax_output(changes)
+        if changes:
+            settings = get_parent(self.context, IMenusContainerTarget)
+            container = settings.menus
+            view = self.request.registry.getMultiAdapter((container, self.request), IMenusView,
+                                                         name=container.__name__)
+            output.setdefault('events', []).append(
+                get_json_table_row_refresh_event(settings, self.request, view.table_class, self.context))
+        return output
+
+
+#
+# Menus table views
+#
+
+class MenusTable(BaseTable):
+    """Menus table"""
+
+    prefix = 'menus'
+    associations_name = 'menus'
+
+    permission = MANAGE_TEMPLATE_PERMISSION
+    hide_header = True
+    hide_body_toolbar = True
+    sortOn = None
+
+    @property
+    def cssClasses(self):
+        classes = ['table', 'table-bordered', 'table-striped', 'table-hover', 'table-tight']
+        permission = self.permission
+        if (not permission) or self.request.has_permission(permission, self.context):
+            classes.append('table-dnd')
+        return {'table': ' '.join(classes)}
+
+    @property
+    def data_attributes(self):
+        menus = getattr(self.context, self.associations_name)
+        attributes = super(MenusTable, self).data_attributes
+        attributes.setdefault('table', {}).update({
+            'id': self.id,
+            'data-ams-location': absolute_url(menus, self.request),
+            'data-ams-tablednd-drag-handle': 'td.sorter',
+            'data-ams-tablednd-drop-target': 'set-menus-order.json',
+            'data-ams-visibility-switcher': 'switch-menu-visibility.json'
+        })
+        return attributes
+
+
+@adapter_config(context=(IMenusContainerTarget, IPyAMSLayer, MenusTable), provides=IValues)
+class MenusTableValuesAdapter(ContextRequestViewAdapter):
+    """Menus table values adapter"""
+
+    @property
+    def values(self):
+        return getattr(self.context, self.view.associations_name).values()
+
+
+@adapter_config(name='sorter', context=(IMenusContainerTarget, IPyAMSLayer, MenusTable),
+                provides=IColumn)
+class MenusTableSorterColumn(SorterColumn):
+    """Menus table sorter column"""
+
+    permission = MANAGE_TEMPLATE_PERMISSION
+
+
+@adapter_config(name='show-hide', context=(IMenusContainerTarget, IPyAMSLayer, MenusTable),
+                provides=IColumn)
+class MenusTableShowHideColumn(VisibilitySwitcherColumn):
+    """Menus table visibility switcher column"""
+
+    permission = MANAGE_TEMPLATE_PERMISSION
+
+
+@adapter_config(name='name', context=(IMenusContainerTarget, IPyAMSLayer, MenusTable), provides=IColumn)
+class MenusTableNameColumn(I18nColumn, I18nAttrColumn):
+    """Menus table name column"""
+
+    _header = _("Label")
+    attrName = 'title'
+    weight = 10
+
+    def renderCell(self, item):
+        return '<span data-ams-stop-propagation="true" ' \
+               '      data-ams-click-handler="MyAMS.skin.switchCellContent" ' \
+               '      data-ams-switch-handler="get-menu-items.json" ' \
+               '      data-ams-switch-target=".menus">' \
+               '    <span class="small hint" title="{hint}" data-ams-hint-gravity="e">' \
+               '        <i class="fa fa-plus-square-o switch"></i>' \
+               '    </span>' \
+               '</span>&nbsp;&nbsp;&nbsp;<span class="title">{title}</span>' \
+               '<div class="inner-table-form menus margin-x-10 margin-bottom-0 padding-left-5"></div>'.format(
+            hint=self.request.localizer.translate(_("Click to see menu items")),
+            title=super(MenusTableNameColumn, self).renderCell(item))
+
+
+@adapter_config(name='trash', context=(IMenusContainerTarget, IPyAMSLayer, MenusTable), provides=IColumn)
+class MenusTableTrashColumn(TrashColumn):
+    """Menus table trash column"""
+
+    permission = MANAGE_TEMPLATE_PERMISSION
+
+
+@implementer(IMenusView)
+class MenusView(InnerTableView):
+    """Menus view"""
+
+    table_class = MenusTable
+
+    @property
+    def actions_context(self):  # define context for internal actions
+        return self.request.registry.getAdapter(self.context, IMenusContainer,
+                                                name=self.table.associations_name)
+
+
+#
+# Menus container views
+#
+
+@view_config(name='set-menus-order.json', context=IMenusContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
+def set_menus_order(request):
+    """Update menus order"""
+    order = list(map(str, json.loads(request.params.get('names'))))
+    request.context.updateOrder(order)
+    return {'status': 'success'}
+
+
+@view_config(name='switch-menu-visibility.json', context=IMenusContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
+def set_menu_visibility(request):
+    """Set menu visibility"""
+    return switch_element_visibility(request, IMenusContainer)
+
+
+@view_config(name='delete-element.json', context=IMenusContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
+def delete_menu(request):
+    """Delete menu"""
+    return delete_container_element(request, ignore_permission=True)
+
+
+@view_config(name='get-menu-items.json', context=IMenusContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
+def get_menu_items_table(request):
+    """Get menu items table"""
+    menu = request.context.get(str(request.params.get('object_name')))
+    if menu is None:
+        raise NotFound()
+    table = MenuLinksTable(menu, request)
+    table.update()
+    return table.render()
+
+
+#
+# Menu links table
+#
+
+class LinksTable(AssociationsTable):
+    """Links base table class"""
+
+    associations_name = None
+
+    @reify
+    def id(self):
+        if IMenu.providedBy(self.context):
+            context = self.context
+        else:
+            context = self.request.registry.getAdapter(self.context, IMenuLinksContainer,
+                                                       name=self.associations_name)
+        return get_table_id(self, context)
+
+    @property
+    def prefix(self):
+        return '{0}_links'.format(self.associations_name)
+
+    permission = MANAGE_TEMPLATE_PERMISSION
+    hide_header = True
+    hide_body_toolbar = True
+
+
+class MenuLinksTable(LinksTable):
+    """Menu links associations table"""
+
+    prefix = 'menu_links'
+    associations_name = ''
+
+    @property
+    def data_attributes(self):
+        attributes = super(LinksTable, self).data_attributes
+        attributes.setdefault('table', {}).update({
+            'data-ams-location': absolute_url(self.context, self.request),
+        })
+        attributes.setdefault('tr', {}).update({'data-ams-stop-propagation': 'true'})
+        return attributes
+
+
+@adapter_config(name='name', context=(IMenu, IPyAMSLayer, MenuLinksTable), provides=IColumn)
+class MenuLinksTableNameColumn(AssociationsTablePublicNameColumn):
+    """Menu links table name column"""
+
+    def renderHeadCell(self):
+        result = super(MenuLinksTableNameColumn, self).renderHeadCell()
+        registry = self.request.registry
+        viewlet = registry.queryMultiAdapter((self.context, self.request, self.table), IViewletManager,
+                                             name='pyams.widget_title')
+        if viewlet is not None:
+            viewlet.update()
+            result += viewlet.render()
+        return result
+
+
+@adapter_config(context=(IMenu, IPyAMSLayer), provides=IMenuLinksView)
+@implementer(IMenuLinksView)
+class MenuLinksView(InnerTableView):
+    """Links base view"""
+
+    table_class = MenuLinksTable
+
+    @property
+    def actions_context(self):  # define context for internal actions
+        return self.request.registry.getAdapter(self.context, IMenuLinksContainer,
+                                                name=self.table.associations_name)
+
+
+#
+# Menu links container views
+#
+
+@view_config(name='set-associations-order.json', context=IMenuLinksContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
+def set_associations_order(request):
+    """Update associations order"""
+    order = list(map(str, json.loads(request.params.get('names'))))
+    request.context.updateOrder(order)
+    return {'status': 'success'}
+
+
+@view_config(name='switch-association-visibility.json', context=IMenuLinksContainer, request_type=IPyAMSLayer,
+             permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
+def set_association_visibility(request):
+    """Set association visibility"""
+    return switch_element_visibility(request, IMenuLinksContainer)
+
+
+#
+# Link add and edit forms
+#
+
+@adapter_config(context=IMenuLink, provides=IFormContextPermissionChecker)
+class MenuLinkPermissionChecker(ContextAdapter):
+    """Menu link permission checker"""
+
+    edit_permission = MANAGE_TEMPLATE_PERMISSION
+
+
+class LinkAJAXAddForm(AJAXAddForm):
+    """Menu link add form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        registry = self.request.registry
+        container = get_parent(self.context, IMenuLinksContainer)
+        view = registry.queryMultiAdapter((container, self.request), IMenuLinksView,
+                                          name=container.__name__)
+        if view is None:
+            view = registry.getMultiAdapter((container, self.request), IMenuLinksView)
+        return {
+            'status': 'success',
+            'message': self.request.localizer.translate(_("Link was correctly added.")),
+            'events': [
+                get_json_switched_table_refresh_event(self.context, self.request, view.table_class)
+            ]
+        }
+
+
+class LinkPropertiesAJAXEditForm(AJAXEditForm):
+    """Menu link properties edit form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        output = AJAXEditForm.get_ajax_output(self, changes)
+        if changes:
+            registry = self.request.registry
+            container = get_parent(self.context, IMenuLinksContainer)
+            view = registry.queryMultiAdapter((container, self.request), IMenuLinksView,
+                                              name=container.__name__)
+            if view is None:
+                view = registry.getMultiAdapter((container, self.request), IMenuLinksView)
+            output.setdefault('events', []).append(
+                get_json_table_row_refresh_event(container, self.request, view.table_class, self.context))
+        return output
+
+
+#
+# Internal links
+#
+
+@viewlet_config(name='add-internal-link.menu', context=IMenuLinksContainerTarget, layer=IPyAMSLayer,
+                view=IMenuLinksView, manager=IToolbarAddingMenu, weight=50)
+@viewlet_config(name='add-internal-link.menu', context=IMenu, layer=IPyAMSLayer,
+                view=MenuLinksTable, manager=IToolbarAddingMenu, permission=MANAGE_TEMPLATE_PERMISSION, weight=50)
+class MenuInternalLinkAddMenu(InternalLinkAddMenu):
+    """Header internal link add menu"""
+
+
+@pagelet_config(name='add-internal-link.html', context=IMenuLinksContainer, layer=IPyAMSLayer,
+                permission=MANAGE_TEMPLATE_PERMISSION)
+@ajax_config(name='add-internal-link.json', context=IMenuLinksContainer, layer=IPyAMSLayer,
+             base=LinkAJAXAddForm)
+class MenuInternalLinkAddForm(InternalLinkAddForm):
+    """Menu internal link add form"""
+
+    edit_permission = MANAGE_TEMPLATE_PERMISSION
+
+    def create(self, data):
+        result = super(MenuInternalLinkAddForm, self).create(data)
+        alsoProvides(result, IMenuInternalLink)
+        return result
+
+    def add(self, object):
+        self.context.append(object)
+
+
+@pagelet_config(name='properties.html', context=IMenuInternalLink, layer=IPyAMSLayer,
+                permission=VIEW_SYSTEM_PERMISSION)
+@ajax_config(name='properties.json', context=IMenuInternalLink, layer=IPyAMSLayer,
+             base=LinkPropertiesAJAXEditForm)
+class MenuInternalLinkPropertiesEditForm(InternalLinkPropertiesEditForm):
+    """Menu internal link properties edit form"""
+
+    edit_permission = None  # managed by IFormContextPermissionChecker adapter
+
+
+#
+# External links
+#
+
+@viewlet_config(name='add-external-link.menu', context=IMenuLinksContainerTarget, view=IMenuLinksView,
+                layer=IPyAMSLayer, manager=IToolbarAddingMenu, weight=51)
+@viewlet_config(name='add-external-link.menu', context=IMenu, layer=IPyAMSLayer,
+                view=MenuLinksTable, manager=IToolbarAddingMenu, permission=MANAGE_TEMPLATE_PERMISSION, weight=51)
+class MenuExternalLinkAddMenu(ExternalLinkAddMenu):
+    """Menu external link add menu"""
+
+
+@pagelet_config(name='add-external-link.html', context=IMenuLinksContainer, layer=IPyAMSLayer,
+                permission=MANAGE_TEMPLATE_PERMISSION)
+@ajax_config(name='add-external-link.json', context=IMenuLinksContainer, layer=IPyAMSLayer,
+             base=LinkAJAXAddForm)
+class MenuExternalLinkAddForm(ExternalLinkAddForm):
+    """Menu external link add form"""
+
+    edit_permission = MANAGE_TEMPLATE_PERMISSION
+
+    def create(self, data):
+        result = super(MenuExternalLinkAddForm, self).create(data)
+        alsoProvides(result, IMenuExternalLink)
+        return result
+
+    def add(self, object):
+        self.context.append(object)
+
+
+@pagelet_config(name='properties.html', context=IMenuExternalLink, layer=IPyAMSLayer,
+                permission=VIEW_SYSTEM_PERMISSION)
+@ajax_config(name='properties.json', context=IMenuExternalLink, layer=IPyAMSLayer,
+             base=LinkPropertiesAJAXEditForm)
+class MenuExternalLinkPropertiesEditForm(ExternalLinkPropertiesEditForm):
+    """Menu external link properties edit form"""
+
+    edit_permission = None  # managed by IFormContextPermissionChecker adapter
--- a/src/pyams_content/generations/__init__.py	Wed Jun 13 10:02:53 2018 +0200
+++ b/src/pyams_content/generations/__init__.py	Wed Jun 13 11:39:37 2018 +0200
@@ -59,7 +59,9 @@
     'pyams_content.shared.common.review ReviewCommentsContainer':
         'pyams_content.features.review ReviewCommentsContainer',
     'pyams_portal.portlets.content ContentPortletSettings':
-        'pyams_content.portlet.content SharedContentPortletSettings'
+        'pyams_content.portlet.content SharedContentPortletSettings',
+    'pyams_content.component.association.menu MenusContainer': 'pyams_content.features.menu MenusContainer',
+    'pyams_content.component.association.menu Menu': 'pyams_content.features.menu Menu'
 }
 
 
Binary file src/pyams_content/locales/fr/LC_MESSAGES/pyams_content.mo has changed
--- a/src/pyams_content/locales/fr/LC_MESSAGES/pyams_content.po	Wed Jun 13 10:02:53 2018 +0200
+++ b/src/pyams_content/locales/fr/LC_MESSAGES/pyams_content.po	Wed Jun 13 11:39:37 2018 +0200
@@ -5,7 +5,7 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2018-06-12 09:21+0200\n"
+"POT-Creation-Date: 2018-06-13 11:31+0200\n"
 "PO-Revision-Date: 2015-09-10 10:42+0200\n"
 "Last-Translator: Thierry Florac <tflorac@ulthar.net>\n"
 "Language-Team: French\n"
@@ -912,6 +912,7 @@
 #: src/pyams_content/shared/form/interfaces/__init__.py:87
 #: src/pyams_content/shared/site/interfaces/__init__.py:117
 #: src/pyams_content/features/alert/interfaces.py:54
+#: src/pyams_content/features/menu/interfaces/__init__.py:58
 msgid "Visible?"
 msgstr "Visible ?"
 
@@ -983,7 +984,7 @@
 msgstr "Liste des types de blocs de contenu autorisés pour ce gabarit."
 
 #: src/pyams_content/component/paragraph/interfaces/__init__.py:85
-#: src/pyams_content/shared/common/zmi/types.py:167
+#: src/pyams_content/shared/common/zmi/types.py:169
 #: src/pyams_content/shared/common/zmi/types.py:380
 msgid "Default paragraphs"
 msgstr "Types de blocs par défaut"
@@ -1268,13 +1269,13 @@
 msgstr "Thèmes sélectionnés"
 
 #: src/pyams_content/component/association/container.py:88
-#: src/pyams_content/component/association/zmi/__init__.py:296
-#: src/pyams_content/component/association/interfaces/__init__.py:86
+#: src/pyams_content/component/association/zmi/__init__.py:297
+#: src/pyams_content/component/association/interfaces/__init__.py:90
 msgid "Associations"
 msgstr "Liens et pièces jointes"
 
 #: src/pyams_content/component/association/zmi/paragraph.py:54
-#: src/pyams_content/component/association/zmi/__init__.py:95
+#: src/pyams_content/component/association/zmi/__init__.py:96
 msgid "Associations..."
 msgstr "Liens et pièces jointes"
 
@@ -1286,36 +1287,37 @@
 msgid "Edit association paragraph properties"
 msgstr "Propriétés du bloc « liens et pièces jointes »"
 
-#: src/pyams_content/component/association/zmi/__init__.py:198
+#: src/pyams_content/component/association/zmi/__init__.py:199
 msgid "Public title"
 msgstr "Libellé public"
 
-#: src/pyams_content/component/association/zmi/__init__.py:216
+#: src/pyams_content/component/association/zmi/__init__.py:217
 msgid "Inner title"
 msgstr "Contenu interne"
 
-#: src/pyams_content/component/association/zmi/__init__.py:232
+#: src/pyams_content/component/association/zmi/__init__.py:233
 msgid "Size"
 msgstr "Taille"
 
-#: src/pyams_content/component/association/zmi/__init__.py:273
-#: src/pyams_content/component/association/zmi/__init__.py:283
+#: src/pyams_content/component/association/zmi/__init__.py:274
+#: src/pyams_content/component/association/zmi/__init__.py:284
 msgid "Associations list"
 msgstr "Liste des liens et pièces jointes"
 
-#: src/pyams_content/component/association/zmi/__init__.py:65
+#: src/pyams_content/component/association/zmi/__init__.py:66
 msgid "Association was correctly added."
 msgstr "L'association a été ajoutée."
 
 #: src/pyams_content/component/association/interfaces/__init__.py:43
+#: src/pyams_content/features/menu/interfaces/__init__.py:59
 msgid "Is this item visible in front-office?"
 msgstr "Si 'non', ce lien ne sera pas présenté aux internautes"
 
-#: src/pyams_content/component/association/interfaces/__init__.py:93
+#: src/pyams_content/component/association/interfaces/__init__.py:97
 msgid "Associations template"
 msgstr "Mode de rendu"
 
-#: src/pyams_content/component/association/interfaces/__init__.py:94
+#: src/pyams_content/component/association/interfaces/__init__.py:98
 msgid "Presentation template used for associations"
 msgstr "Modèle de présentation utilisé par ce bloc de contenu"
 
@@ -1815,24 +1817,24 @@
 msgid "Data type label"
 msgstr "Libellé du type"
 
-#: src/pyams_content/shared/common/zmi/types.py:183
+#: src/pyams_content/shared/common/zmi/types.py:185
 #: src/pyams_content/shared/common/zmi/types.py:396
 msgid "Default associations"
 msgstr "Liens et pièces jointes par défaut"
 
-#: src/pyams_content/shared/common/zmi/types.py:211
+#: src/pyams_content/shared/common/zmi/types.py:213
 msgid "Content data types"
 msgstr "Types de contenus"
 
-#: src/pyams_content/shared/common/zmi/types.py:234
+#: src/pyams_content/shared/common/zmi/types.py:236
 msgid "Add data type"
 msgstr "Ajouter un type"
 
-#: src/pyams_content/shared/common/zmi/types.py:246
+#: src/pyams_content/shared/common/zmi/types.py:248
 msgid "Add new data type"
 msgstr "Ajout d'un type de contenu"
 
-#: src/pyams_content/shared/common/zmi/types.py:283
+#: src/pyams_content/shared/common/zmi/types.py:285
 msgid "Data type properties"
 msgstr "Propriétés du type de contenu"
 
@@ -1856,7 +1858,7 @@
 msgid "No currently defined data type."
 msgstr "Aucun type de contenu n'est actuellement défini."
 
-#: src/pyams_content/shared/common/zmi/types.py:273
+#: src/pyams_content/shared/common/zmi/types.py:275
 msgid "Specified type name is already used!"
 msgstr "Le nom indiqué pour ce type de contenu est déjà utilisé !"
 
@@ -1868,7 +1870,7 @@
 msgid "Specified subtype name is already used!"
 msgstr "Le nom indiqué pour ce sous-type de contenu est déjà utilisé !"
 
-#: src/pyams_content/shared/common/zmi/types.py:155
+#: src/pyams_content/shared/common/zmi/types.py:157
 msgid "Click to see subtypes"
 msgstr "Montrer ou caher les sous-types"
 
@@ -2593,6 +2595,7 @@
 #: src/pyams_content/shared/common/interfaces/types.py:40
 #: src/pyams_content/shared/form/zmi/field.py:159
 #: src/pyams_content/shared/form/interfaces/__init__.py:62
+#: src/pyams_content/features/menu/zmi/__init__.py:204
 msgid "Label"
 msgstr "Libellé"
 
@@ -4693,6 +4696,38 @@
 msgid "No currently defined alert."
 msgstr "Aucune alerte n'est définie actuellement."
 
+#: src/pyams_content/features/menu/zmi/__init__.py:79
+msgid "Add menu..."
+msgstr "Ajouter un menu"
+
+#: src/pyams_content/features/menu/zmi/__init__.py:90
+msgid "Add new menu"
+msgstr "Ajout d'un menu"
+
+#: src/pyams_content/features/menu/zmi/__init__.py:120
+msgid "Edit menu properties"
+msgstr "Propriétés du menu"
+
+#: src/pyams_content/features/menu/zmi/__init__.py:108
+msgid "Menu was correctly added."
+msgstr "Le menu a été ajouté."
+
+#: src/pyams_content/features/menu/zmi/__init__.py:394
+msgid "Link was correctly added."
+msgstr "Le lien a été ajouté."
+
+#: src/pyams_content/features/menu/zmi/__init__.py:218
+msgid "Click to see menu items"
+msgstr "Montrer ou cacher les éléments du menu"
+
+#: src/pyams_content/features/menu/interfaces/__init__.py:63
+msgid "Menu title"
+msgstr "Libellé"
+
+#: src/pyams_content/features/menu/interfaces/__init__.py:64
+msgid "Displayed menu label"
+msgstr "Libellé du menu"
+
 #: src/pyams_content/features/footer/zmi/__init__.py:56
 msgid "Page footer"
 msgstr "Pied de pages"
@@ -4701,7 +4736,7 @@
 msgid "Edit footer settings"
 msgstr "Paramétrage des pieds de pages"
 
-#: src/pyams_content/features/footer/zmi/__init__.py:172
+#: src/pyams_content/features/footer/zmi/__init__.py:185
 msgid "Footer renderer settings"
 msgstr "Propriétés du mode de rendu"
 
@@ -4925,7 +4960,7 @@
 msgid "Edit header settings"
 msgstr "Paramétrage des en-têtes de pages"
 
-#: src/pyams_content/features/header/zmi/__init__.py:178
+#: src/pyams_content/features/header/zmi/__init__.py:191
 msgid "Header renderer settings"
 msgstr "Propriétés du mode de rendu"
 
@@ -5128,9 +5163,6 @@
 #~ msgid "The content « {0} » has been archived"
 #~ msgstr "Le contenu « {0} » a été archivé"
 
-#~ msgid "Add comment..."
-#~ msgstr "Ajouter un commentaire"
-
 #~ msgid "Publication settings"
 #~ msgstr "Dates de publication et de retrait"
 
--- a/src/pyams_content/locales/pyams_content.pot	Wed Jun 13 10:02:53 2018 +0200
+++ b/src/pyams_content/locales/pyams_content.pot	Wed Jun 13 11:39:37 2018 +0200
@@ -6,7 +6,7 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2018-06-12 09:21+0200\n"
+"POT-Creation-Date: 2018-06-13 11:31+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"
@@ -873,6 +873,7 @@
 #: ./src/pyams_content/shared/form/interfaces/__init__.py:87
 #: ./src/pyams_content/shared/site/interfaces/__init__.py:117
 #: ./src/pyams_content/features/alert/interfaces.py:54
+#: ./src/pyams_content/features/menu/interfaces/__init__.py:58
 msgid "Visible?"
 msgstr ""
 
@@ -940,7 +941,7 @@
 msgstr ""
 
 #: ./src/pyams_content/component/paragraph/interfaces/__init__.py:85
-#: ./src/pyams_content/shared/common/zmi/types.py:167
+#: ./src/pyams_content/shared/common/zmi/types.py:169
 #: ./src/pyams_content/shared/common/zmi/types.py:380
 msgid "Default paragraphs"
 msgstr ""
@@ -1217,13 +1218,13 @@
 msgstr ""
 
 #: ./src/pyams_content/component/association/container.py:88
-#: ./src/pyams_content/component/association/zmi/__init__.py:296
-#: ./src/pyams_content/component/association/interfaces/__init__.py:86
+#: ./src/pyams_content/component/association/zmi/__init__.py:297
+#: ./src/pyams_content/component/association/interfaces/__init__.py:90
 msgid "Associations"
 msgstr ""
 
 #: ./src/pyams_content/component/association/zmi/paragraph.py:54
-#: ./src/pyams_content/component/association/zmi/__init__.py:95
+#: ./src/pyams_content/component/association/zmi/__init__.py:96
 msgid "Associations..."
 msgstr ""
 
@@ -1235,36 +1236,37 @@
 msgid "Edit association paragraph properties"
 msgstr ""
 
-#: ./src/pyams_content/component/association/zmi/__init__.py:198
+#: ./src/pyams_content/component/association/zmi/__init__.py:199
 msgid "Public title"
 msgstr ""
 
-#: ./src/pyams_content/component/association/zmi/__init__.py:216
+#: ./src/pyams_content/component/association/zmi/__init__.py:217
 msgid "Inner title"
 msgstr ""
 
-#: ./src/pyams_content/component/association/zmi/__init__.py:232
+#: ./src/pyams_content/component/association/zmi/__init__.py:233
 msgid "Size"
 msgstr ""
 
-#: ./src/pyams_content/component/association/zmi/__init__.py:273
-#: ./src/pyams_content/component/association/zmi/__init__.py:283
+#: ./src/pyams_content/component/association/zmi/__init__.py:274
+#: ./src/pyams_content/component/association/zmi/__init__.py:284
 msgid "Associations list"
 msgstr ""
 
-#: ./src/pyams_content/component/association/zmi/__init__.py:65
+#: ./src/pyams_content/component/association/zmi/__init__.py:66
 msgid "Association was correctly added."
 msgstr ""
 
 #: ./src/pyams_content/component/association/interfaces/__init__.py:43
+#: ./src/pyams_content/features/menu/interfaces/__init__.py:59
 msgid "Is this item visible in front-office?"
 msgstr ""
 
-#: ./src/pyams_content/component/association/interfaces/__init__.py:93
+#: ./src/pyams_content/component/association/interfaces/__init__.py:97
 msgid "Associations template"
 msgstr ""
 
-#: ./src/pyams_content/component/association/interfaces/__init__.py:94
+#: ./src/pyams_content/component/association/interfaces/__init__.py:98
 msgid "Presentation template used for associations"
 msgstr ""
 
@@ -1733,24 +1735,24 @@
 msgid "Data type label"
 msgstr ""
 
-#: ./src/pyams_content/shared/common/zmi/types.py:183
+#: ./src/pyams_content/shared/common/zmi/types.py:185
 #: ./src/pyams_content/shared/common/zmi/types.py:396
 msgid "Default associations"
 msgstr ""
 
-#: ./src/pyams_content/shared/common/zmi/types.py:211
+#: ./src/pyams_content/shared/common/zmi/types.py:213
 msgid "Content data types"
 msgstr ""
 
-#: ./src/pyams_content/shared/common/zmi/types.py:234
+#: ./src/pyams_content/shared/common/zmi/types.py:236
 msgid "Add data type"
 msgstr ""
 
-#: ./src/pyams_content/shared/common/zmi/types.py:246
+#: ./src/pyams_content/shared/common/zmi/types.py:248
 msgid "Add new data type"
 msgstr ""
 
-#: ./src/pyams_content/shared/common/zmi/types.py:283
+#: ./src/pyams_content/shared/common/zmi/types.py:285
 msgid "Data type properties"
 msgstr ""
 
@@ -1774,7 +1776,7 @@
 msgid "No currently defined data type."
 msgstr ""
 
-#: ./src/pyams_content/shared/common/zmi/types.py:273
+#: ./src/pyams_content/shared/common/zmi/types.py:275
 msgid "Specified type name is already used!"
 msgstr ""
 
@@ -1786,7 +1788,7 @@
 msgid "Specified subtype name is already used!"
 msgstr ""
 
-#: ./src/pyams_content/shared/common/zmi/types.py:155
+#: ./src/pyams_content/shared/common/zmi/types.py:157
 msgid "Click to see subtypes"
 msgstr ""
 
@@ -2471,6 +2473,7 @@
 #: ./src/pyams_content/shared/common/interfaces/types.py:40
 #: ./src/pyams_content/shared/form/zmi/field.py:159
 #: ./src/pyams_content/shared/form/interfaces/__init__.py:62
+#: ./src/pyams_content/features/menu/zmi/__init__.py:204
 msgid "Label"
 msgstr ""
 
@@ -4441,6 +4444,38 @@
 msgid "No currently defined alert."
 msgstr ""
 
+#: ./src/pyams_content/features/menu/zmi/__init__.py:79
+msgid "Add menu..."
+msgstr ""
+
+#: ./src/pyams_content/features/menu/zmi/__init__.py:90
+msgid "Add new menu"
+msgstr ""
+
+#: ./src/pyams_content/features/menu/zmi/__init__.py:120
+msgid "Edit menu properties"
+msgstr ""
+
+#: ./src/pyams_content/features/menu/zmi/__init__.py:108
+msgid "Menu was correctly added."
+msgstr ""
+
+#: ./src/pyams_content/features/menu/zmi/__init__.py:394
+msgid "Link was correctly added."
+msgstr ""
+
+#: ./src/pyams_content/features/menu/zmi/__init__.py:218
+msgid "Click to see menu items"
+msgstr ""
+
+#: ./src/pyams_content/features/menu/interfaces/__init__.py:63
+msgid "Menu title"
+msgstr ""
+
+#: ./src/pyams_content/features/menu/interfaces/__init__.py:64
+msgid "Displayed menu label"
+msgstr ""
+
 #: ./src/pyams_content/features/footer/zmi/__init__.py:56
 msgid "Page footer"
 msgstr ""
@@ -4449,7 +4484,7 @@
 msgid "Edit footer settings"
 msgstr ""
 
-#: ./src/pyams_content/features/footer/zmi/__init__.py:172
+#: ./src/pyams_content/features/footer/zmi/__init__.py:185
 msgid "Footer renderer settings"
 msgstr ""
 
@@ -4659,7 +4694,7 @@
 msgid "Edit header settings"
 msgstr ""
 
-#: ./src/pyams_content/features/header/zmi/__init__.py:178
+#: ./src/pyams_content/features/header/zmi/__init__.py:191
 msgid "Header renderer settings"
 msgstr ""