Added redirections manager
authorThierry Florac <thierry.florac@onf.fr>
Thu, 19 Jul 2018 16:15:30 +0200
changeset 864 209432f09f9f
parent 863 edcf61caaf3b
child 865 bddcf038737f
Added redirections manager
src/pyams_content/features/redirect/__init__.py
src/pyams_content/features/redirect/container.py
src/pyams_content/features/redirect/interfaces/__init__.py
src/pyams_content/features/redirect/tween.py
src/pyams_content/features/redirect/zmi/__init__.py
src/pyams_content/features/redirect/zmi/container.py
src/pyams_content/features/redirect/zmi/templates/manager-test.pt
src/pyams_content/include.py
src/pyams_content/locales/fr/LC_MESSAGES/pyams_content.mo
src/pyams_content/locales/fr/LC_MESSAGES/pyams_content.po
src/pyams_content/locales/pyams_content.pot
src/pyams_content/root/__init__.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/redirect/__init__.py	Thu Jul 19 16:15:30 2018 +0200
@@ -0,0 +1,88 @@
+#
+# 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 re
+
+# import interfaces
+from pyams_content.features.redirect.interfaces import IRedirectionRule
+from pyams_content.interfaces import MANAGE_SITE_ROOT_PERMISSION
+from pyams_form.interfaces.form import IFormContextPermissionChecker
+
+# import packages
+from persistent import Persistent
+from pyams_sequence.reference import get_reference_target
+from pyams_utils.adapter import adapter_config, ContextAdapter
+from pyams_utils.url import canonical_url
+from pyams_utils.zodb import volatile_property
+from zope.container.contained import Contained
+from zope.interface import implementer
+from zope.schema.fieldproperty import FieldProperty
+
+
+@implementer(IRedirectionRule)
+class RedirectionRule(Persistent, Contained):
+    """Redirection rule persistent class"""
+
+    active = FieldProperty(IRedirectionRule['active'])
+    chained = FieldProperty(IRedirectionRule['chained'])
+    permanent = FieldProperty(IRedirectionRule['permanent'])
+    _url_pattern = FieldProperty(IRedirectionRule['url_pattern'])
+    reference = FieldProperty(IRedirectionRule['reference'])
+    target_url = FieldProperty(IRedirectionRule['target_url'])
+
+    @property
+    def url_pattern(self):
+        return self._url_pattern
+
+    @url_pattern.setter
+    def url_pattern(self, value):
+        if value != self._url_pattern:
+            self._url_pattern = value
+            del self.pattern
+
+    @volatile_property
+    def target(self):
+        return get_reference_target(self.reference)
+
+    def get_target(self, state=None):
+        if not state:
+            return self.target
+        else:
+            return get_reference_target(self.reference, state)
+
+    @volatile_property
+    def pattern(self):
+        return re.compile(self.url_pattern)
+
+    def match(self, source_url):
+        return self.pattern.match(source_url)
+
+    def rewrite(self, source_url, request):
+        target_url = None
+        if self.reference:
+            target = self.target
+            if target is not None:
+                target_url = canonical_url(target, request)
+        else:
+            target_url = self.pattern.sub(self.target_url, source_url)
+        return target_url
+
+
+@adapter_config(context=IRedirectionRule, provides=IFormContextPermissionChecker)
+class RedirectionRulePermissionChecker(ContextAdapter):
+    """Redirection rule permission checker"""
+
+    edit_permission = MANAGE_SITE_ROOT_PERMISSION
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/redirect/container.py	Thu Jul 19 16:15:30 2018 +0200
@@ -0,0 +1,103 @@
+#
+# 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.redirect.interfaces import IRedirectionManager, IRedirectionRule, IRedirectionManagerTarget, \
+    REDIRECT_MANAGER_KEY
+from zope.location.interfaces import ISublocations
+from zope.traversing.interfaces import ITraversable
+
+# import packages
+from pyams_catalog.utils import index_object
+from pyams_utils.adapter import adapter_config, get_annotation_adapter, ContextAdapter
+from pyramid.response import Response
+from zope.container.ordered import OrderedContainer
+from zope.interface import implementer
+from zope.location.location import locate
+
+from pyams_content import _
+
+
+@implementer(IRedirectionManager)
+class RedirectManager(OrderedContainer):
+    """Redirect manager"""
+
+    last_id = 1
+
+    def append(self, value, notify=True):
+        key = str(self.last_id)
+        if not notify:
+            # pre-locate item to avoid multiple notifications
+            locate(value, self, key)
+        self[key] = value
+        self.last_id += 1
+        if not notify:
+            # make sure that item is correctly indexed
+            index_object(value)
+
+    def get_active_items(self):
+        yield from filter(lambda x: IRedirectionRule(x).active, self.values())
+
+    def get_response(self, request):
+        target_url = request.path_qs
+        for rule in self.get_active_items():
+            match = rule.match(target_url)
+            if match:
+                target_url = rule.rewrite(target_url, request)
+                if not rule.chained:
+                    response = Response()
+                    response.status_code = 301 if rule.permanent else 302
+                    response.location = target_url
+                    return response
+
+    def test_rules(self, source_url, request, check_inactive_rules=False):
+        if check_inactive_rules:
+            rules = self.values()
+        else:
+            rules = self.get_active_items()
+        for rule in rules:
+            match = rule.match(source_url)
+            if match:
+                target_url = rule.rewrite(source_url, request)
+                yield rule, source_url, target_url
+                if not rule.chained:
+                    raise StopIteration
+                source_url = target_url
+            else:
+                yield rule, source_url, request.localizer.translate(_("not matching"))
+
+
+@adapter_config(context=IRedirectionManagerTarget, provides=IRedirectionManager)
+def redirection_manager_factory(context):
+    """Redirection manager factory"""
+    return get_annotation_adapter(context, REDIRECT_MANAGER_KEY, RedirectManager, name='++redirect++')
+
+
+@adapter_config(name='redirect', context=IRedirectionManagerTarget, provides=ITraversable)
+class RedirectionManagerNamespace(ContextAdapter):
+    """Redirection manager ++redirect++ namespace"""
+
+    def traverse(self, name, furtherpath=None):
+        return IRedirectionManager(self.context)
+
+
+@adapter_config(name='redirect', context=IRedirectionManagerTarget, provides=ISublocations)
+class RedirectManagerSublocations(ContextAdapter):
+    """redirection manager sub-locations adapter"""
+
+    def sublocations(self):
+        return IRedirectionManager(self.context).values()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/redirect/interfaces/__init__.py	Thu Jul 19 16:15:30 2018 +0200
@@ -0,0 +1,102 @@
+#
+# 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.interfaces.container import IOrderedContainer
+from pyams_sequence.interfaces import IInternalReference
+
+# import packages
+from pyams_sequence.schema import InternalReferenceField
+from zope.container.constraints import contains, containers
+from zope.interface import Interface, Attribute, invariant, Invalid
+from zope.schema import Bool, TextLine, Choice
+
+from pyams_content import _
+
+
+REDIRECT_MANAGER_KEY = 'pyams_content.redirect'
+
+
+class IRedirectionRule(IInternalReference):
+    """Redirection rule interface"""
+
+    containers('.IRedirectManager')
+
+    active = Bool(title=_("Active rule?"),
+                  description=_("If 'no', selected rule is inactive"),
+                  required=True,
+                  default=False)
+
+    chained = Bool(title=_("Chained rule?"),
+                   description=_("If 'no', and if this rule is matching received request URL, the rule "
+                                 "returns a redirection response; otherwise, the rule just rewrites the "
+                                 "input URL which is forwarded to the next rule"),
+                   required=True,
+                   default=False)
+
+    permanent = Bool(title=_("Permanent redirect?"),
+                     description=_("Define if this redirection should be permanent or temporary"),
+                     required=True,
+                     default=True)
+
+    url_pattern = TextLine(title=_("URL pattern"),
+                           description=_("Regexp pattern of matching URLs for this redirection rule"),
+                           required=True)
+
+    pattern = Attribute("Compiled URL pattern")
+
+    reference = InternalReferenceField(title=_("Internal redirection target"),
+                                       description=_("Internal redirection reference. You can search a reference using "
+                                                     "'+' followed by internal number, of by entering text matching "
+                                                     "content title."),
+                                       required=False)
+
+    target_url = TextLine(title=_("Target URL"),
+                          description=_("URL to which source URL should be redirected"),
+                          required=False)
+
+    @invariant
+    def check_reference_and_target(self):
+        if self.reference and self.target_url:
+            raise Invalid(_("You can only provide an internal reference OR a target URL"))
+        elif not (self.reference or self.target_url):
+            raise Invalid(_("You must provide an internal reference OR a target URL"))
+
+    def match(self, source_url):
+        """Return regexp URL match on given URL"""
+
+    def rewrite(self, source_url, request):
+        """Rewrite given source URL"""
+
+
+class IRedirectionManager(IOrderedContainer):
+    """Redirection manager"""
+
+    contains(IRedirectionRule)
+
+    def get_active_items(self):
+        """Get iterator over active items"""
+
+    def get_response(self, request):
+        """Get new response for given request"""
+
+    def test_rules(self, source_url, request, check_inactive_rules=False):
+        """Test rules against given URL"""
+
+
+class IRedirectionManagerTarget(Interface):
+    """Redirection manager target marker interface"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/redirect/tween.py	Thu Jul 19 16:15:30 2018 +0200
@@ -0,0 +1,49 @@
+#
+# 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.redirect.interfaces import IRedirectionManager
+
+# import packages
+from pyramid.exceptions import NotFound
+from pyramid.httpexceptions import HTTPNotFound
+
+
+def redirect_tween_factory(handler, registry):
+    """Redirect tween factory
+
+    This tween is used to handle NotFound errors: when a request which raises
+    a NotFound error is served, we look info redirects configuration to check if
+    given URL is matching any defined redirection, in which case another HTTPRedirect
+    response is returned with a new location; another content using a proxy request
+    can also be returned.
+    """
+
+    def redirect_tween(request):
+        try:
+            response = handler(request)
+        except (NotFound, HTTPNotFound):
+            manager = IRedirectionManager(request.root, None)
+            if manager is not None:
+                response = manager.get_response(request)
+                if response is not None:
+                    return response
+            raise
+        else:
+            return response
+
+    return redirect_tween
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/redirect/zmi/__init__.py	Thu Jul 19 16:15:30 2018 +0200
@@ -0,0 +1,126 @@
+#
+# 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.
+#
+from pyams_form.help import FormHelp
+from pyams_form.interfaces.form import IFormHelp
+from pyams_utils.adapter import adapter_config
+from pyams_zmi.layer import IAdminLayer
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+
+# import interfaces
+from pyams_content.features.redirect.interfaces import IRedirectionRule, IRedirectionManagerTarget, IRedirectionManager
+from pyams_content.interfaces import MANAGE_SITE_ROOT_PERMISSION
+from pyams_skin.interfaces.viewlet import IWidgetTitleViewletManager
+from pyams_skin.layer import IPyAMSLayer
+
+# import packages
+from pyams_content.features.redirect import RedirectionRule
+from pyams_content.features.redirect.zmi.container import RedirectionsContainerView, RedirectionsContainerTable
+from pyams_form.form import ajax_config, AJAXAddForm
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.event import get_json_table_row_refresh_event
+from pyams_skin.viewlet.toolbar import ToolbarAction
+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 z3c.form import field
+
+from pyams_content import _
+
+
+@viewlet_config(name='add-rule.action', context=IRedirectionManagerTarget, layer=IPyAMSLayer,
+                view=RedirectionsContainerView, manager=IWidgetTitleViewletManager,
+                permission=MANAGE_SITE_ROOT_PERMISSION, weight=1)
+class RedirectionRuleAddAction(ToolbarAction):
+    """Redirection rule add action"""
+
+    label = _("Add rule")
+    label_css_class = 'fa fa-fw fa-plus'
+    url = 'add-rule.html'
+    modal_target = True
+
+
+@pagelet_config(name='add-rule.html', context=IRedirectionManagerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_SITE_ROOT_PERMISSION)
+@ajax_config(name='add-rule.json', context=IRedirectionManagerTarget, layer=IPyAMSLayer, base=AJAXAddForm)
+class RedirectionRuleAddForm(AdminDialogAddForm):
+    """Redirection rule add form"""
+
+    dialog_class = 'modal-large'
+    legend = _("Add new redirection rule")
+    icon_css_class = 'fa fa-fw fa-map-signs'
+
+    fields = field.Fields(IRedirectionRule).omit('__parent__', '__name__', 'active', 'chained')
+    edit_permission = MANAGE_SITE_ROOT_PERMISSION
+
+    def create(self, data):
+        return RedirectionRule()
+
+    def add(self, object):
+        IRedirectionManager(self.context).append(object)
+
+    def nextURL(self):
+        return absolute_url(self.context, self.request, 'redirections.html')
+
+
+@pagelet_config(name='properties.html', context=IRedirectionRule, layer=IPyAMSLayer,
+                permission=MANAGE_SITE_ROOT_PERMISSION)
+@ajax_config(name='properties.json', context=IRedirectionRule, layer=IPyAMSLayer)
+class RedirectionRulePropertiesEditForm(AdminDialogEditForm):
+    """Redirection rule properties edit form"""
+
+    dialog_class = 'modal-large'
+    prefix = 'rule_properties.'
+
+    legend = _("Edit redirection rule properties")
+    icon_css_class = 'fa fa-fw fa-map-signs'
+
+    fields = field.Fields(IRedirectionRule).omit('__parent__', '__name__', 'active', 'chained')
+    edit_permission = MANAGE_SITE_ROOT_PERMISSION
+
+    def get_ajax_output(self, changes):
+        output = super(self.__class__, self).get_ajax_output(changes)
+        updated = changes.get(IRedirectionRule, ())
+        if updated:
+            target = get_parent(self.context, IRedirectionManagerTarget)
+            output.setdefault('events', []).append(
+                get_json_table_row_refresh_event(target, self.request, RedirectionsContainerTable, self.context))
+        return output
+
+
+@adapter_config(context=(IRedirectionManagerTarget, IAdminLayer, RedirectionRuleAddForm), provides=IFormHelp)
+@adapter_config(context=(IRedirectionRule, IAdminLayer, RedirectionRulePropertiesEditForm), provides=IFormHelp)
+class RedirectionRuleFormHelp(FormHelp):
+    """Redirection rule form help"""
+
+    message = _("""URL pattern and target URL are defined by *regular expressions* (see |regexp|).
+    
+In URL pattern, you can use any valid regular expression element, notably:
+
+- « .* » to match any list of characters 
+
+- « ( ) » to "memorize" parts of the URL which can be replaced into target URL
+
+- special characters (like "+") must be escaped with an « \\\\ ».
+
+In target URL, memorized parts can be reused using « \\\\1 », « \\\\2 » and so on, where given number is
+the order of the matching pattern element.
+
+.. |regexp| raw:: html
+
+    <a href="https://docs.python.org/3/library/re.html" target="_blank">Python Regular Expressions</a>
+""")
+    message_format = 'rest'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/redirect/zmi/container.py	Thu Jul 19 16:15:30 2018 +0200
@@ -0,0 +1,376 @@
+#
+# 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.
+#
+from pyams_form.form import ajax_config, AJAXAddForm
+from pyams_form.interfaces.form import IWidgetsSuffixViewletsManager
+from pyams_form.schema import CloseButton
+from pyams_skin.help import ContentHelp
+from pyams_skin.interfaces.viewlet import IToolbarViewletManager
+from pyams_skin.skin import apply_skin
+from pyams_skin.viewlet.toolbar import ToolbarAction
+from pyams_template.template import template_config
+from pyams_utils.request import copy_request
+from pyams_zmi.form import AdminDialogAddForm
+
+__docformat__ = 'restructuredtext'
+
+
+# import standard library
+import json
+
+# import interfaces
+from pyams_content.features.redirect.interfaces import IRedirectionManagerTarget, IRedirectionManager
+from pyams_content.interfaces import MANAGE_SITE_ROOT_PERMISSION
+from pyams_i18n.interfaces import II18n
+from pyams_sequence.interfaces import ISequentialIdInfo
+from pyams_skin.interfaces import IPageHeader, IUserSkinnable, IContentHelp
+from pyams_skin.layer import IPyAMSLayer
+from pyams_zmi.interfaces.menu import ISiteManagementMenu
+from pyams_zmi.layer import IAdminLayer
+from z3c.table.interfaces import IValues, IColumn
+
+# import packages
+from pyams_content.skin import pyams_content
+from pyams_pagelet.pagelet import pagelet_config
+from pyams_skin.page import DefaultPageHeaderAdapter
+from pyams_skin.table import BaseTable, SorterColumn, VisibilitySwitcherColumn, TrashColumn, I18nColumn
+from pyams_skin.viewlet.menu import MenuItem
+from pyams_utils.adapter import adapter_config, ContextRequestViewAdapter
+from pyams_utils.fanstatic import get_resource_path
+from pyams_utils.url import absolute_url
+from pyams_viewlet.viewlet import viewlet_config, Viewlet
+from pyams_zmi.view import ContainerAdminView
+from pyramid.decorator import reify
+from pyramid.exceptions import NotFound
+from pyramid.view import view_config
+from z3c.form import field, button
+from z3c.table.column import GetAttrColumn
+from zope.interface import Interface
+from zope.schema import TextLine, Bool
+
+from pyams_content import _
+
+
+@viewlet_config(name='redirections.menu', context=IRedirectionManagerTarget, layer=IPyAMSLayer,
+                manager=ISiteManagementMenu, permission=MANAGE_SITE_ROOT_PERMISSION, weight=35)
+class RedirectionMenu(MenuItem):
+    """Redirection manager menu"""
+
+    label = _("Redirections")
+    icon_class = 'fa-map-signs'
+    url = '#redirections.html'
+
+
+class RedirectionsContainerTable(BaseTable):
+    """Redirections container table"""
+
+    prefix = 'redirections'
+
+    hide_header = True
+    sortOn = None
+
+    cssClasses = {'table': 'table table-bordered table-striped table-hover table-tight table-dnd'}
+
+    @property
+    def data_attributes(self):
+        attributes = super(RedirectionsContainerTable, self).data_attributes
+        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(IRedirectionManager(self.context), self.request),
+            'data-ams-tablednd-drag-handle': 'td.sorter',
+            'data-ams-tablednd-drop-target': 'set-rules-order.json',
+            'data-ams-active-icon-on': 'fa fa-fw fa-check-square-o',
+            'data-ams-active-icon-off': 'fa fa-fw fa-square-o txt-color-silver opacity-75',
+            'data-ams-chained-icon-on': 'fa fa-fw fa-chain',
+            'data-ams-chained-icon-off': 'fa fa-fw fa-chain txt-color-silver opacity-50'
+        })
+        attributes.setdefault('td', {}).update({
+            'data-ams-attribute-switcher': self.get_switcher_target,
+            'data-ams-switcher-attribute-name': self.get_switcher_attribute
+        })
+        return attributes
+
+    @staticmethod
+    def get_switcher_target(element, column):
+        if column.__name__ == 'enable-disable':
+            return 'switch-rule-activity.json'
+        elif column.__name__ == 'chain-unchain':
+            return 'switch-rule-chain.json'
+
+    @staticmethod
+    def get_switcher_attribute(element, column):
+        if column.__name__ == 'enable-disable':
+            return 'active'
+        elif column.__name__ == 'chain-unchain':
+            return 'chained'
+
+    @reify
+    def values(self):
+        return list(super(RedirectionsContainerTable, self).values)
+
+    def render(self):
+        if not self.values:
+            translate = self.request.localizer.translate
+            return translate(_("No currently defined redirection rule."))
+        return super(RedirectionsContainerTable, self).render()
+
+
+@adapter_config(context=(IRedirectionManagerTarget, IPyAMSLayer, RedirectionsContainerTable), provides=IValues)
+class RedirectionsContainerValues(ContextRequestViewAdapter):
+    """Redirections container values"""
+
+    @property
+    def values(self):
+        return IRedirectionManager(self.context).values()
+
+
+@adapter_config(name='sorter', context=(IRedirectionManagerTarget, IPyAMSLayer, RedirectionsContainerTable),
+                provides=IColumn)
+class RedirectionsContainerSorterColumn(SorterColumn):
+    """Redirections container sorter column"""
+
+
+@view_config(name='set-rules-order.json', context=IRedirectionManager, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_ROOT_PERMISSION, renderer='json', xhr=True)
+def set_rules_order(request):
+    """Update redirection rules order"""
+    order = list(map(str, json.loads(request.params.get('names'))))
+    request.context.updateOrder(order)
+    return {'status': 'success'}
+
+
+@adapter_config(name='enable-disable', context=(IRedirectionManagerTarget, IPyAMSLayer, RedirectionsContainerTable),
+                provides=IColumn)
+class RedirectionsContainerShowHideColumn(VisibilitySwitcherColumn):
+    """Redirections container activity switcher column"""
+
+    switch_attribute = 'active'
+    visible_icon_class = 'fa fa-fw fa-check-square-o'
+    hidden_icon_class = 'fa fa-fw fa-square-o txt-color-silver opacity-75'
+
+    icon_hint = _("Enable/disable rule")
+
+    url = 'MyAMS.container.switchElementAttribute'
+    weight = 6
+
+
+@view_config(name='switch-rule-activity.json', context=IRedirectionManager, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_ROOT_PERMISSION, renderer='json', xhr=True)
+def switch_rule_activity(request):
+    """Switch rule activity"""
+    container = IRedirectionManager(request.context)
+    rule = container.get(str(request.params.get('object_name')))
+    if rule is None:
+        raise NotFound()
+    rule.active = not rule.active
+    return {'on': rule.active}
+
+
+@adapter_config(name='chain-unchain', context=(IRedirectionManagerTarget, IPyAMSLayer, RedirectionsContainerTable),
+                provides=IColumn)
+class RedirectionsContainerChainedColumn(VisibilitySwitcherColumn):
+    """Redirections container chained switcher column"""
+
+    switch_attribute = 'chained'
+    visible_icon_class = 'fa fa-fw fa-chain'
+    hidden_icon_class = 'fa fa-fw fa-chain txt-color-silver opacity-50'
+
+    icon_hint = _("Chain/unchain rule")
+
+    url = 'MyAMS.container.switchElementAttribute'
+    weight = 7
+
+
+@view_config(name='switch-rule-chain.json', context=IRedirectionManager, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_ROOT_PERMISSION, renderer='json', xhr=True)
+def switch_rule_chain(request):
+    """Switch rule chain"""
+    container = IRedirectionManager(request.context)
+    rule = container.get(str(request.params.get('object_name')))
+    if rule is None:
+        raise NotFound()
+    rule.chained = not rule.chained
+    return {'on': rule.chained}
+
+
+@adapter_config(name='name', context=(IRedirectionManagerTarget, IPyAMSLayer, RedirectionsContainerTable),
+                provides=IColumn)
+class RedirectionsContainerNameColumn(I18nColumn, GetAttrColumn):
+    """Redirections container name column"""
+
+    _header = _("URL pattern")
+    attrName = 'url_pattern'
+    weight = 10
+
+
+@adapter_config(name='target', context=(IRedirectionManagerTarget, IPyAMSLayer, RedirectionsContainerTable),
+                provides=IColumn)
+class RedirectionsContainerTargetColumn(I18nColumn, GetAttrColumn):
+    """Redirections container target column"""
+
+    _header = _("Target")
+    attrName = 'target_url'
+    weight = 20
+
+    def getValue(self, obj):
+        if obj.reference:
+            target = obj.target
+            return '{0} ({1})'.format(II18n(target).query_attribute('title', request=self.request),
+                                      ISequentialIdInfo(target).get_short_oid())
+        else:
+            return super(RedirectionsContainerTargetColumn, self).getValue(obj)
+
+
+@adapter_config(name='trash', context=(IRedirectionManagerTarget, IPyAMSLayer, RedirectionsContainerTable),
+                provides=IColumn)
+class RedirectionsContainerTrashColumn(TrashColumn):
+    """Redirections container trash column"""
+
+    permission = MANAGE_SITE_ROOT_PERMISSION
+
+
+@pagelet_config(name='redirections.html', context=IRedirectionManagerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_SITE_ROOT_PERMISSION)
+class RedirectionsContainerView(ContainerAdminView):
+    """Redirections container view"""
+
+    title = _("Redirections list")
+    table_class = RedirectionsContainerTable
+
+
+@adapter_config(context=(IRedirectionManagerTarget, IAdminLayer, RedirectionsContainerView), provides=IPageHeader)
+class RedirectionsContainerViewHeaderAdapter(DefaultPageHeaderAdapter):
+    """Redirections container view header adapter"""
+
+    icon_class = 'fa fa-fw fa-map-signs'
+
+
+@adapter_config(context=(IRedirectionManagerTarget, IAdminLayer, RedirectionsContainerView), provides=IContentHelp)
+class RedirectionsContainerHelpAdapter(ContentHelp):
+    """Redirections container help adapter"""
+
+    header = _("Redirection rules")
+    message = _("""Redirection rules are use to handle redirections responses when a request generates 
+a famous « 404 NotFound » error.
+
+Redirections are particularly useful when you are migrating from a previous site and don't want to lose 
+your SEO.
+
+You can define a set of rules which will be applied to every \"NotFound\" request; rules are based on 
+regular expressions which are applied to input URL: if the rule is \"matching\", the target URL is rewritten
+and a \"Redirect\" response is send.
+
+You can chain rules together: when a rule is chained, it's rewritten URL is passed as input URL to the 
+next rule, until a matching rule is found.
+""")
+    message_format = 'rest'
+
+
+#
+# Redirections container test form
+#
+
+@viewlet_config(name='test.action', context=IRedirectionManagerTarget, layer=IAdminLayer,
+                view=RedirectionsContainerView, manager=IToolbarViewletManager,
+                permission=MANAGE_SITE_ROOT_PERMISSION, weight=75)
+class RedirectionsContainerTestAction(ToolbarAction):
+    """redirections container test action"""
+
+    label = _("Test")
+
+    group_css_class = 'btn-group margin-left-5'
+    label_css_class = 'fa fa-fw fa-magic'
+    css_class = 'btn btn-xs btn-default'
+
+    url = 'test-redirection-rules.html'
+    modal_target = True
+
+
+class IRedirectionsContainerTestFields(Interface):
+    """Redirections container test fields"""
+
+    source_url = TextLine(title=_("Test URL"),
+                          required=True)
+
+    check_inactive_rules = Bool(title=_("Check inactive rules?"),
+                                description=_("If 'yes', inactive rules will also be tested"),
+                                required=True,
+                                default=False)
+
+
+class IRedirectionsContainerTestButtons(Interface):
+    """Redirections container test form buttons"""
+
+    close = CloseButton(name='close', title=_("Close"))
+    test = button.Button(name='test', title=_("Test rules"))
+
+
+@pagelet_config(name='test-redirection-rules.html', context=IRedirectionManagerTarget, layer=IPyAMSLayer,
+                permission=MANAGE_SITE_ROOT_PERMISSION)
+class RedirectionsContainerTestForm(AdminDialogAddForm):
+    """Redirections container test form"""
+
+    dialog_class = 'modal-max'
+    legend = _("Test redirection rules")
+    icon_css_class = 'fa fa-fw fa-magic'
+
+    prefix = 'rules_test_form.'
+    fields = field.Fields(IRedirectionsContainerTestFields)
+    buttons = button.Buttons(IRedirectionsContainerTestButtons)
+    ajax_handler = 'test-redirection-rules.json'
+    edit_permission = MANAGE_SITE_ROOT_PERMISSION
+
+    @property
+    def form_target(self):
+        return '#{0}_test_result'.format(self.id)
+
+    def updateActions(self):
+        super(RedirectionsContainerTestForm, self).updateActions()
+        if 'test' in self.actions:
+            self.actions['test'].addClass('btn-primary')
+
+    def createAndAdd(self, data):
+        data = data.get(self, data)
+        request = copy_request(self.request)
+        apply_skin(request, IUserSkinnable(self.context).get_skin())
+        return IRedirectionManager(self.context).test_rules(data['source_url'], request, data['check_inactive_rules'])
+
+
+@viewlet_config(name='test-indexer-process.suffix', layer=IAdminLayer, manager=IWidgetsSuffixViewletsManager,
+                view=RedirectionsContainerTestForm, weight=50)
+@template_config(template='templates/manager-test.pt')
+class RedirectionsContainerTestSuffix(Viewlet):
+    """Redirections container test form suffix"""
+
+
+@view_config(name='test-redirection-rules.json', context=IRedirectionManagerTarget, request_type=IPyAMSLayer,
+             permission=MANAGE_SITE_ROOT_PERMISSION, renderer='json', xhr=True)
+class RedirectionsContainerAJAXTestForm(AJAXAddForm, RedirectionsContainerTestForm):
+    """Redirections container test form, JSON renderer"""
+
+    def get_ajax_output(self, changes):
+        message = []
+        translate = self.request.localizer.translate
+        for rule, source_url, target_url in changes:
+            if not message:
+                message.append('{:<40}  {:<40}  =>  {:<40}'.format(translate(_("URL pattern")),
+                                                                   translate(_("Input URL")),
+                                                                   translate(_("Output URL"))))
+                message.append('{:<40}  {:<40}  =>  {:<40}'.format('-' * 40, '-' * 40, '-' * 40))
+            message.append('{:<40}  {:<40}  =>  {:<40}'.format(rule.url_pattern, source_url, target_url))
+        if not message:
+            message.append(translate(_("No matching rule!")))
+        return {
+            'status': 'success',
+            'content': {'html': '\n'.join(message)},
+            'close_form': False
+        }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_content/features/redirect/zmi/templates/manager-test.pt	Thu Jul 19 16:15:30 2018 +0200
@@ -0,0 +1,4 @@
+<div class="no-widget-toolbar">
+	<pre class="height-min-200"
+		 tal:attributes="id string:${view.__parent__.id}_test_result"></pre>
+</div>
--- a/src/pyams_content/include.py	Thu Jul 19 10:38:08 2018 +0200
+++ b/src/pyams_content/include.py	Thu Jul 19 16:15:30 2018 +0200
@@ -18,6 +18,7 @@
 # import interfaces
 
 # import packages
+from pyramid.tweens import MAIN
 
 
 def include_package(config):
@@ -26,6 +27,9 @@
     # add translations
     config.add_translation_dirs('pyams_content:locales')
 
+    # add custom twwen
+    config.add_tween('pyams_content.features.redirect.tween.redirect_tween_factory', over=MAIN)
+
     # add custom routes
     config.add_route('oid_access', '/+/{oid}*view')
 
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	Thu Jul 19 10:38:08 2018 +0200
+++ b/src/pyams_content/locales/fr/LC_MESSAGES/pyams_content.po	Thu Jul 19 16:15:30 2018 +0200
@@ -5,7 +5,7 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2018-07-18 14:46+0200\n"
+"POT-Creation-Date: 2018-07-19 15:57+0200\n"
 "PO-Revision-Date: 2015-09-10 10:42+0200\n"
 "Last-Translator: Thierry Florac <tflorac@ulthar.net>\n"
 "Language-Team: French\n"
@@ -1389,27 +1389,27 @@
 msgid "Content collections"
 msgstr "Collections associées au contenu"
 
-#: src/pyams_content/component/theme/zmi/manager.py:51
+#: src/pyams_content/component/theme/zmi/manager.py:58
 msgid "Tags settings..."
 msgstr "Paramétrage des tags"
 
-#: src/pyams_content/component/theme/zmi/manager.py:65
+#: src/pyams_content/component/theme/zmi/manager.py:72
 msgid "Selected tags"
 msgstr "Tags sélectionnés"
 
-#: src/pyams_content/component/theme/zmi/manager.py:101
+#: src/pyams_content/component/theme/zmi/manager.py:108
 msgid "Themes settings..."
 msgstr "Paramétrage des thèmes"
 
-#: src/pyams_content/component/theme/zmi/manager.py:115
+#: src/pyams_content/component/theme/zmi/manager.py:122
 msgid "Selected themes"
 msgstr "Thèmes sélectionnés"
 
-#: src/pyams_content/component/theme/zmi/manager.py:151
+#: src/pyams_content/component/theme/zmi/manager.py:158
 msgid "Collections settings..."
 msgstr "Paramétrage des collections"
 
-#: src/pyams_content/component/theme/zmi/manager.py:165
+#: src/pyams_content/component/theme/zmi/manager.py:172
 msgid "Selected collections"
 msgstr "Collections sélectionnées"
 
@@ -1556,6 +1556,7 @@
 
 #: src/pyams_content/component/links/interfaces/__init__.py:61
 #: src/pyams_content/shared/logo/interfaces/__init__.py:56
+#: src/pyams_content/features/redirect/interfaces/__init__.py:68
 msgid "Target URL"
 msgstr "URL cible"
 
@@ -4411,7 +4412,7 @@
 msgid "Default length used for inner tables and dashboards"
 msgstr "Longueur par défaut des tableaux internes et des tableaux de bord"
 
-#: src/pyams_content/root/__init__.py:68
+#: src/pyams_content/root/__init__.py:70
 msgid "Site root"
 msgstr "Racine du site"
 
@@ -5079,6 +5080,247 @@
 msgid "No currently defined alert."
 msgstr "Aucune alerte n'est définie actuellement."
 
+#: src/pyams_content/features/redirect/container.py:81
+msgid "not matching"
+msgstr "pas de correspondance"
+
+#: src/pyams_content/features/redirect/zmi/__init__.py:50
+msgid "Add rule"
+msgstr "Ajouter une règle"
+
+#: src/pyams_content/features/redirect/zmi/__init__.py:63
+msgid "Add new redirection rule"
+msgstr "Ajout d'une règle de redirection"
+
+#: src/pyams_content/features/redirect/zmi/__init__.py:88
+msgid "Edit redirection rule properties"
+msgstr "Propriétés de la règle de redirection"
+
+#: src/pyams_content/features/redirect/zmi/__init__.py:109
+msgid ""
+"URL pattern and target URL are defined by *regular expressions* (see |"
+"regexp|).\n"
+"    \n"
+"In URL pattern, you can use any valid regular expression element, notably:\n"
+"\n"
+"- « .* » to match any list of characters \n"
+"\n"
+"- « ( ) » to \"memorize\" parts of the URL which can be replaced into target "
+"URL\n"
+"\n"
+"- special characters (like \"+\") must be escaped with an « \\\\ ».\n"
+"\n"
+"In target URL, memorized parts can be reused using « \\\\1 », « \\\\2 » and "
+"so on, where given number is\n"
+"the order of the matching pattern element.\n"
+"\n"
+".. |regexp| raw:: html\n"
+"\n"
+"    <a href=\"https://docs.python.org/3/library/re.html\" target=\"_blank"
+"\">Python Regular Expressions</a>\n"
+msgstr ""
+"Le schéma d'URL et l'URL cible sont définis en tant que « expressions "
+"rationelles » (voir |regexp|).\n"
+"\n"
+"Dans le schéma d'URL utilisé pour identifier les requêtes en entrée, vous "
+"pouvez utiliser tout élément d'une expression rationnelle valide, "
+"notamment :\n"
+"\n"
+"- « .* » pour rechercher n'importe quelle suite de caractères\n"
+"\n"
+"- « ^ » et « $ » pour identifier le début ou la fin de l'URL\n"
+"\n"
+"- « ( ) » pour \"mémoriser\" certains éléments de l'URL qui pourront être "
+"repris dans l'URL cible\n"
+"\n"
+"- les caractères spéciaux (comme les \"+\") doivent être protégés par un "
+"caractère « \\\\ ».\n"
+"\n"
+"For exemple : dans le schéma « ^/.*?oid=([a-z0-9]+)$ », toute URL contenant "
+"un paramètre \"oid\" composé de minuscules et/ou de chiffres sera mémorisé "
+"pour pouvoir être réutilisé dans l'URL cible.\n"
+"\n"
+"Dans l'URL cible, les éléments mémorisés peuvent être réutilisés en "
+"utilisant une expression comme « \\\\1 », « \\\\2 » (et ainsi de suite), le "
+"chiffre indiquant la position de l'élément dans la liste des éléments "
+"mémorisés.\n"
+"\n"
+".. |regexp| raw:: html\n"
+"\n"
+"    <a href=\"https://docs.python.org/fr/3/library/re.html\" target=\"_blank"
+"\">Expressions rationnelles en Python</a>\n"
+
+#: src/pyams_content/features/redirect/zmi/container.py:67
+msgid "Redirections"
+msgstr "Redirections"
+
+#: src/pyams_content/features/redirect/zmi/container.py:160
+msgid "Enable/disable rule"
+msgstr "Activer/désactiver la règle"
+
+#: src/pyams_content/features/redirect/zmi/container.py:187
+msgid "Chain/unchain rule"
+msgstr "Enchaîner la règle avec la suivante"
+
+#: src/pyams_content/features/redirect/zmi/container.py:210
+#: src/pyams_content/features/redirect/zmi/container.py:365
+#: src/pyams_content/features/redirect/interfaces/__init__.py:56
+msgid "URL pattern"
+msgstr "Schéma d'URL"
+
+#: src/pyams_content/features/redirect/zmi/container.py:220
+msgid "Target"
+msgstr "Cible"
+
+#: src/pyams_content/features/redirect/zmi/container.py:246
+msgid "Redirections list"
+msgstr "Liste des règles de redirection"
+
+#: src/pyams_content/features/redirect/zmi/container.py:261
+msgid "Redirection rules"
+msgstr "Règles de redirection"
+
+#: src/pyams_content/features/redirect/zmi/container.py:262
+msgid ""
+"Redirection rules are use to handle redirections responses when a request "
+"generates \n"
+"a famous « 404 NotFound » error.\n"
+"\n"
+"Redirections are particularly useful when you are migrating from a previous "
+"site and don't want to lose \n"
+"your SEO.\n"
+"\n"
+"You can define a set of rules which will be applied to every \"NotFound\" "
+"request; rules are based on \n"
+"regular expressions which are applied to input URL: if the rule is \"matching"
+"\", the target URL is rewritten\n"
+"and a \"Redirect\" response is send.\n"
+"\n"
+"You can chain rules together: when a rule is chained, it's rewritten URL is "
+"passed as input URL to the \n"
+"next rule, until a matching rule is found.\n"
+msgstr ""
+"Les règles de redirection sont utilisées pour transmettre des réponses de redirection "
+"au lieu de la fameuse erreur « 404 - Page non trouvée ».\n"
+"\n"
+"La gestion des redirections est particulièrement importante en phase de migration d'un site web, "
+"pour éviter les liens cassés, ne pas perdre votre référencement et faciliter la mise à jour "
+"des moteurs de recherche.\n"
+"\n"
+"Vous pouvez définir un ensemble de règles qui seront appliquées dès lors qu'une requête "
+"adressée au serveur génère une erreur de page non trouvée ; les règles sont basées sur des "
+"expressions rationnelles que l'on applique à l'URL de la requête reçue : si la règle correspond, "
+"l'URL est réécrite et une réponse de redirection vers cette nouvelle URL est renvoyée.\n"
+"\n"
+"Vous pouvez également enchaîner les règles : lorsqu'une règle est \"chaînée\", la nouvelle URL "
+"qu'elle génère est passée aux règles suivantes, jusqu'à ce qu'une règle s'applique à cette "
+"nouvelle URL.\n"
+
+#: src/pyams_content/features/redirect/zmi/container.py:288
+msgid "Test"
+msgstr "Tester !"
+
+#: src/pyams_content/features/redirect/zmi/container.py:323
+msgid "Test redirection rules"
+msgstr "Test des règles de redirection"
+
+#: src/pyams_content/features/redirect/zmi/container.py:301
+msgid "Test URL"
+msgstr "URL à tester"
+
+#: src/pyams_content/features/redirect/zmi/container.py:304
+msgid "Check inactive rules?"
+msgstr "Tester les règles inactive ?"
+
+#: src/pyams_content/features/redirect/zmi/container.py:305
+msgid "If 'yes', inactive rules will also be tested"
+msgstr "Si 'oui', les règles inactives seront également testées"
+
+#: src/pyams_content/features/redirect/zmi/container.py:313
+msgid "Close"
+msgstr "Fermer"
+
+#: src/pyams_content/features/redirect/zmi/container.py:314
+msgid "Test rules"
+msgstr "Tester cette URL"
+
+#: src/pyams_content/features/redirect/zmi/container.py:123
+msgid "No currently defined redirection rule."
+msgstr "Aucune règle de redirection n'est définie actuellement."
+
+#: src/pyams_content/features/redirect/zmi/container.py:371
+msgid "No matching rule!"
+msgstr "Aucune règle ne correspond !"
+
+#: src/pyams_content/features/redirect/zmi/container.py:366
+msgid "Input URL"
+msgstr "URL en entrée"
+
+#: src/pyams_content/features/redirect/zmi/container.py:367
+msgid "Output URL"
+msgstr "URL générée"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:39
+msgid "Active rule?"
+msgstr "Règle active ?"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:40
+msgid "If 'no', selected rule is inactive"
+msgstr "Si 'non', la règle est inactive"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:44
+msgid "Chained rule?"
+msgstr "Règle chaînée ?"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:45
+msgid ""
+"If 'no', and if this rule is matching received request URL, the rule returns "
+"a redirection response; otherwise, the rule just rewrites the input URL "
+"which is forwarded to the next rule"
+msgstr ""
+"Si 'non', et si cette règle correspond à l'URL reçue en entrée, une réponde "
+"de redirection est renvoyée directement ; dans le cas contraire, l'URL "
+"générée par cette règle est passée en entrée de la règle suivante"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:51
+msgid "Permanent redirect?"
+msgstr "Redirection permanente ?"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:52
+msgid "Define if this redirection should be permanent or temporary"
+msgstr ""
+"Indique si cette redirection doit être considérée comme permanente ou "
+"temporaire"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:57
+msgid "Regexp pattern of matching URLs for this redirection rule"
+msgstr "Modèle de l'URL d'origine de cette règle de redirection"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:62
+msgid "Internal redirection target"
+msgstr "Redirection interne"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:63
+msgid ""
+"Internal redirection reference. You can search a reference using '+' "
+"followed by internal number, of by entering text matching content title."
+msgstr ""
+"Référence interne vers une cible de redirection. Vous pouvez la rechercher "
+"par des mots de son titre, ou par son numéro interne (précédé d'un '+')"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:69
+msgid "URL to which source URL should be redirected"
+msgstr "URL vers laquelle l'URL d'origine doit être redirigée"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:75
+msgid "You can only provide an internal reference OR a target URL"
+msgstr ""
+"Vous ne pouvez fournir qu'une référence interne OU une URL de redirection !"
+
+#: src/pyams_content/features/redirect/interfaces/__init__.py:77
+msgid "You must provide an internal reference OR a target URL"
+msgstr "Vous devez fournir une référence interne OU une URL de redirection !"
+
 #: src/pyams_content/features/menu/zmi/__init__.py:81
 msgid "Add menu..."
 msgstr "Ajouter un menu"
@@ -5405,6 +5647,21 @@
 msgid "Hidden header"
 msgstr "Ne pas afficher d'en-tête de pages"
 
+#~ msgid "Rewrite to another internal reference or URL"
+#~ msgstr "Rediriger vers une autre référence ou URL interne"
+
+#~ msgid "Redirect to another external URL"
+#~ msgstr "Rediriger vers une URL externe"
+
+#~ msgid "Return content in proxy mode without redirection"
+#~ msgstr "Charger le contenu ciblé sans redirection"
+
+#~ msgid "Redirect mode"
+#~ msgstr "Mode de redirection"
+
+#~ msgid "Mode of redirection for this URL pattern"
+#~ msgstr "Mode de redirection utilisé par cette règle"
+
 #~ msgid "Subtitle"
 #~ msgstr "Sous-titre"
 
--- a/src/pyams_content/locales/pyams_content.pot	Thu Jul 19 10:38:08 2018 +0200
+++ b/src/pyams_content/locales/pyams_content.pot	Thu Jul 19 16:15:30 2018 +0200
@@ -6,7 +6,7 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: PACKAGE 1.0\n"
-"POT-Creation-Date: 2018-07-18 14:46+0200\n"
+"POT-Creation-Date: 2018-07-19 15:57+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"
@@ -1337,27 +1337,27 @@
 msgid "Content collections"
 msgstr ""
 
-#: ./src/pyams_content/component/theme/zmi/manager.py:51
+#: ./src/pyams_content/component/theme/zmi/manager.py:58
 msgid "Tags settings..."
 msgstr ""
 
-#: ./src/pyams_content/component/theme/zmi/manager.py:65
+#: ./src/pyams_content/component/theme/zmi/manager.py:72
 msgid "Selected tags"
 msgstr ""
 
-#: ./src/pyams_content/component/theme/zmi/manager.py:101
+#: ./src/pyams_content/component/theme/zmi/manager.py:108
 msgid "Themes settings..."
 msgstr ""
 
-#: ./src/pyams_content/component/theme/zmi/manager.py:115
+#: ./src/pyams_content/component/theme/zmi/manager.py:122
 msgid "Selected themes"
 msgstr ""
 
-#: ./src/pyams_content/component/theme/zmi/manager.py:151
+#: ./src/pyams_content/component/theme/zmi/manager.py:158
 msgid "Collections settings..."
 msgstr ""
 
-#: ./src/pyams_content/component/theme/zmi/manager.py:165
+#: ./src/pyams_content/component/theme/zmi/manager.py:172
 msgid "Selected collections"
 msgstr ""
 
@@ -1500,6 +1500,7 @@
 
 #: ./src/pyams_content/component/links/interfaces/__init__.py:61
 #: ./src/pyams_content/shared/logo/interfaces/__init__.py:56
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:68
 msgid "Target URL"
 msgstr ""
 
@@ -4131,7 +4132,7 @@
 msgid "Default length used for inner tables and dashboards"
 msgstr ""
 
-#: ./src/pyams_content/root/__init__.py:68
+#: ./src/pyams_content/root/__init__.py:70
 msgid "Site root"
 msgstr ""
 
@@ -4781,6 +4782,185 @@
 msgid "No currently defined alert."
 msgstr ""
 
+#: ./src/pyams_content/features/redirect/container.py:81
+msgid "not matching"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/__init__.py:50
+msgid "Add rule"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/__init__.py:63
+msgid "Add new redirection rule"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/__init__.py:88
+msgid "Edit redirection rule properties"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/__init__.py:109
+msgid ""
+"URL pattern and target URL are defined by *regular expressions* (see |regexp|).\n"
+"    \n"
+"In URL pattern, you can use any valid regular expression element, notably:\n"
+"\n"
+"- « .* » to match any list of characters \n"
+"\n"
+"- « ( ) » to \"memorize\" parts of the URL which can be replaced into target URL\n"
+"\n"
+"- special characters (like \"+\") must be escaped with an « \\\\ ».\n"
+"\n"
+"In target URL, memorized parts can be reused using « \\\\1 », « \\\\2 » and so on, where given number is\n"
+"the order of the matching pattern element.\n"
+"\n"
+".. |regexp| raw:: html\n"
+"\n"
+"    <a href=\"https://docs.python.org/3/library/re.html\" target=\"_blank\">Python Regular Expressions</a>\n"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:67
+msgid "Redirections"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:160
+msgid "Enable/disable rule"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:187
+msgid "Chain/unchain rule"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:210
+#: ./src/pyams_content/features/redirect/zmi/container.py:365
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:56
+msgid "URL pattern"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:220
+msgid "Target"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:246
+msgid "Redirections list"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:261
+msgid "Redirection rules"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:262
+msgid ""
+"Redirection rules are use to handle redirections responses when a request generates \n"
+"a famous « 404 NotFound » error.\n"
+"\n"
+"Redirections are particularly useful when you are migrating from a previous site and don't want to lose \n"
+"your SEO.\n"
+"\n"
+"You can define a set of rules which will be applied to every \"NotFound\" request; rules are based on \n"
+"regular expressions which are applied to input URL: if the rule is \"matching\", the target URL is rewritten\n"
+"and a \"Redirect\" response is send.\n"
+"\n"
+"You can chain rules together: when a rule is chained, it's rewritten URL is passed as input URL to the \n"
+"next rule, until a matching rule is found.\n"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:288
+msgid "Test"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:323
+msgid "Test redirection rules"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:301
+msgid "Test URL"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:304
+msgid "Check inactive rules?"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:305
+msgid "If 'yes', inactive rules will also be tested"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:313
+msgid "Close"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:314
+msgid "Test rules"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:123
+msgid "No currently defined redirection rule."
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:371
+msgid "No matching rule!"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:366
+msgid "Input URL"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/zmi/container.py:367
+msgid "Output URL"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:39
+msgid "Active rule?"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:40
+msgid "If 'no', selected rule is inactive"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:44
+msgid "Chained rule?"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:45
+msgid ""
+"If 'no', and if this rule is matching received request URL, the rule returns "
+"a redirection response; otherwise, the rule just rewrites the input URL which"
+" is forwarded to the next rule"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:51
+msgid "Permanent redirect?"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:52
+msgid "Define if this redirection should be permanent or temporary"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:57
+msgid "Regexp pattern of matching URLs for this redirection rule"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:62
+msgid "Internal redirection target"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:63
+msgid ""
+"Internal redirection reference. You can search a reference using '+' followed"
+" by internal number, of by entering text matching content title."
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:69
+msgid "URL to which source URL should be redirected"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:75
+msgid "You can only provide an internal reference OR a target URL"
+msgstr ""
+
+#: ./src/pyams_content/features/redirect/interfaces/__init__.py:77
+msgid "You must provide an internal reference OR a target URL"
+msgstr ""
+
 #: ./src/pyams_content/features/menu/zmi/__init__.py:81
 msgid "Add menu..."
 msgstr ""
--- a/src/pyams_content/root/__init__.py	Thu Jul 19 10:38:08 2018 +0200
+++ b/src/pyams_content/root/__init__.py	Thu Jul 19 16:15:30 2018 +0200
@@ -24,6 +24,7 @@
 from pyams_content.features.footer.interfaces import IFooterTarget
 from pyams_content.features.header.interfaces import IHeaderTarget
 from pyams_content.features.preview.interfaces import IPreviewTarget
+from pyams_content.features.redirect.interfaces import IRedirectionManagerTarget
 from pyams_content.interfaces import WEBMASTER_ROLE, OPERATOR_ROLE
 from pyams_content.root.interfaces import ISiteRootRoles, ISiteRootConfiguration, ISiteRoot, \
     ISiteRootToolsConfiguration, ISiteRootBackOfficeConfiguration
@@ -52,7 +53,8 @@
 
 
 @implementer(IDefaultProtectionPolicy, ISiteRoot, ISiteRootRoles, IPortalContext, ITagsManagerTarget,
-             IIllustrationTarget, IHeaderTarget, IFooterTarget, IAlertTarget, IPreviewTarget)
+             IIllustrationTarget, IHeaderTarget, IFooterTarget, IAlertTarget, IRedirectionManagerTarget,
+             IPreviewTarget)
 class SiteRoot(ProtectedObject, BaseSiteRoot, UserSkinnableContent):
     """Main site root"""