Added support for custom template container CSS class
authorThierry Florac <tflorac@ulthar.net>
Thu, 05 Aug 2021 09:27:48 +0200
changeset 289 fca4100c1733
parent 288 390514bce78a
child 290 3977d9c8618d
Added support for custom template container CSS class
src/pyams_portal/interfaces.py
src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.mo
src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.po
src/pyams_portal/skin/template.py
src/pyams_portal/slot.py
src/pyams_portal/template.py
src/pyams_portal/templates/pagelet.pt
src/pyams_portal/zmi/container.py
src/pyams_portal/zmi/layout.py
src/pyams_portal/zmi/page.py
src/pyams_portal/zmi/portlet.py
src/pyams_portal/zmi/template.py
src/pyams_portal/zmi/templates/template-properties.pt
--- a/src/pyams_portal/interfaces.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/interfaces.py	Thu Aug 05 09:27:48 2021 +0200
@@ -228,9 +228,13 @@
                    required=True,
                    default=True)
 
+    container_css_class = TextLine(title=_("Container CSS class"),
+                                   description=_("CSS class applied to this slot container"),
+                                   required=False)
+
     xs_width = Int(title=_("Extra small device width"),
-                   description=_("Slot width, in columns count, on extra small devices (phones...); "
-                                 "set to 0 to hide the portlet"),
+                   description=_("Slot width, in columns count, on extra small devices "
+                                 "(phones...); set to 0 to hide the portlet"),
                    required=False,
                    min=0,
                    max=12)
@@ -243,15 +247,15 @@
                    max=12)
 
     md_width = Int(title=_("Medium devices width"),
-                   description=_("Slot width, in columns count, on medium desktop devices (>= 992 pixels); "
-                                 "set to 0 to hide the portlet"),
+                   description=_("Slot width, in columns count, on medium desktop devices "
+                                 "(>= 992 pixels); set to 0 to hide the portlet"),
                    required=False,
                    min=0,
                    max=12)
 
     lg_width = Int(title=_("Large devices width"),
-                   description=_("Slot width, in columns count, on large desktop devices (>= 1200 pixels); "
-                                 "set to 0 to hide the portlet"),
+                   description=_("Slot width, in columns count, on large desktop devices "
+                                 "(>= 1200 pixels); set to 0 to hide the portlet"),
                    required=False,
                    min=0,
                    max=12)
@@ -374,6 +378,11 @@
                     description=_("Two registered templates can't share the same name..."),
                     required=True)
 
+    css_class = TextLine(title=_("CSS class"),
+                         description=_("This CSS class can be included into main presentation "
+                                       "layout..."),
+                         required=False)
+
 
 class IPortalTemplateContainer(IContainer, IAttributeAnnotatable):
     """Portal template container interface"""
Binary file src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.mo has changed
--- a/src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.po	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/locales/fr/LC_MESSAGES/pyams_portal.po	Thu Aug 05 09:27:48 2021 +0200
@@ -126,7 +126,7 @@
 
 #: src/pyams_portal/interfaces.py:259
 msgid "CSS class"
-msgstr "Class CSS"
+msgstr "Classe CSS"
 
 #: src/pyams_portal/interfaces.py:260
 msgid "CSS class applied to this slot"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/pyams_portal/skin/template.py	Thu Aug 05 09:27:48 2021 +0200
@@ -0,0 +1,47 @@
+#
+# Copyright (c) 2015-2021 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.
+#
+
+"""PyAMS_portal.skin.template module
+
+This module provides a TALES extension which allows to get current template
+configuration and CSS class.
+"""
+
+__docformat__ = 'restructuredtext'
+
+from zope.interface import Interface
+
+from pyams_portal.interfaces import IPortalContext, IPortalPage
+from pyams_skin.layer import IPyAMSLayer
+from pyams_utils.adapter import ContextRequestViewAdapter, adapter_config
+from pyams_utils.interfaces.tales import ITALESExtension
+from pyams_utils.traversing import get_parent
+
+
+@adapter_config(name='template_container_class',
+                context=(Interface, IPyAMSLayer, Interface),
+                provides=ITALESExtension)
+class TemplateContainerClassTALESExtension(ContextRequestViewAdapter):
+    """Template class getter TALES extension"""
+
+    def render(self, context=None, default=''):
+        if context is None:
+            context = self.context
+        result = default
+        portal_context = get_parent(context, IPortalContext)
+        if portal_context is not None:
+            page = IPortalPage(portal_context, None)
+            if page is not None:
+                template = page.template
+                if template is not None:
+                    result = template.css_class
+        return result
--- a/src/pyams_portal/slot.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/slot.py	Thu Aug 05 09:27:48 2021 +0200
@@ -33,6 +33,7 @@
     slot_name = FieldProperty(ISlotConfiguration['slot_name'])
     _portlet_ids = FieldProperty(ISlotConfiguration['portlet_ids'])
     visible = FieldProperty(ISlotConfiguration['visible'])
+    container_css_class = FieldProperty(ISlotConfiguration['container_css_class'])
     xs_width = FieldProperty(ISlotConfiguration['xs_width'])
     sm_width = FieldProperty(ISlotConfiguration['sm_width'])
     md_width = FieldProperty(ISlotConfiguration['md_width'])
--- a/src/pyams_portal/template.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/template.py	Thu Aug 05 09:27:48 2021 +0200
@@ -80,6 +80,7 @@
     """Portal template class"""
 
     name = FieldProperty(IPortalTemplate['name'])
+    css_class = FieldProperty(IPortalTemplate['css_class'])
 
     content_name = _("Portal template")
 
--- a/src/pyams_portal/templates/pagelet.pt	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/templates/pagelet.pt	Thu Aug 05 09:27:48 2021 +0200
@@ -5,14 +5,17 @@
 		<div class="row" tal:repeat="row range(template_config.rows)">
 			<div class="slots">
 				<tal:loop repeat="slot_name template_config.get_slots(row)">
-					<div class="slot"
-						 tal:define="slot_config template_config.get_slot_configuration(slot_name)"
+					<div tal:define="slot_config template_config.get_slot_configuration(slot_name)"
 						 tal:condition="slot_config.visible"
-						 tal:attributes="class string:slot col ${slot_config.get_css_class()}">
-						<div class="portlets">
-							<div tal:repeat="portlet_id template_config.slot_config[slot_name].portlet_ids"
-								 class="portlet ${view.get_portlet_css_class(portlet_id)}">
-								<tal:var replace="structure view.render_portlet(portlet_id)">Content</tal:var>
+						 tal:omit-tag="not:slot_config.container_css_class"
+						 class="${slot_config.container_css_class}">
+						<div class="slot"
+							 tal:attributes="class string:slot col ${slot_config.get_css_class()}">
+							<div class="portlets">
+								<div tal:repeat="portlet_id template_config.slot_config[slot_name].portlet_ids"
+									 class="portlet ${view.get_portlet_css_class(portlet_id)}">
+									<tal:var replace="structure view.render_portlet(portlet_id)">Content</tal:var>
+								</div>
 							</div>
 						</div>
 					</div>
--- a/src/pyams_portal/zmi/container.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/zmi/container.py	Thu Aug 05 09:27:48 2021 +0200
@@ -70,7 +70,8 @@
         return absolute_url(self.request.root, self.request, 'admin#portal-templates.html')
 
 
-@adapter_config(context=(IPortalTemplateContainer, IAdminLayer, ITable), provides=ITableElementEditor)
+@adapter_config(context=(IPortalTemplateContainer, IAdminLayer, ITable),
+                provides=ITableElementEditor)
 class PortalTemplateContainerTableElementEditor(DefaultElementEditorAdapter):
     """Portal template container table element editor"""
 
@@ -83,7 +84,8 @@
         return resource_url(site, self.request, 'admin#{0}'.format(self.view_name))
 
 
-@viewlet_config(name='portal-templates.menu', context=ISite, layer=IAdminLayer, manager=IControlPanelMenu,
+@viewlet_config(name='portal-templates.menu',
+                context=ISite, layer=IAdminLayer, manager=IControlPanelMenu,
                 permission=MANAGE_TEMPLATE_PERMISSION, weight=20)
 @viewletmanager_config(name='portal-templates.menu', context=ISite, layer=IAdminLayer)
 @implementer(IPortalTemplateContainerMenu)
@@ -114,10 +116,12 @@
         return attributes
 
 
-@adapter_config(context=(IPortalTemplate, IAdminLayer, PortalTemplateContainerTable), provides=ITableElementEditor)
+@adapter_config(context=(IPortalTemplate, IAdminLayer, PortalTemplateContainerTable),
+                provides=ITableElementEditor)
 class PortalTemplateTableElementEditor(DefaultElementEditorAdapter):
     """Portal template table element editor"""
 
+    view_name = 'layout.html'
     modal_target = False
 
     @property
@@ -125,14 +129,16 @@
         return resource_url(self.context, self.request, 'admin#{0}'.format(self.view_name))
 
 
-@adapter_config(name='name', context=(Interface, IAdminLayer, PortalTemplateContainerTable), provides=IColumn)
+@adapter_config(name='name', context=(Interface, IAdminLayer, PortalTemplateContainerTable),
+                provides=IColumn)
 class PortalTemplateContainerNameColumn(NameColumn):
     """Portal template container name column"""
 
     attrName = 'name'
 
 
-@adapter_config(name='trash', context=(Interface, IAdminLayer, PortalTemplateContainerTable), provides=IColumn)
+@adapter_config(name='trash', context=(Interface, IAdminLayer, PortalTemplateContainerTable),
+                provides=IColumn)
 class PortalTemplateContainerTrashColumn(TrashColumn):
     """Portal template container trash column"""
 
@@ -140,14 +146,16 @@
     permission = MANAGE_TEMPLATE_PERMISSION
 
 
-@view_config(name='delete-element.json', context=IPortalTemplateContainer, request_type=IPyAMSLayer,
+@view_config(name='delete-element.json',
+             context=IPortalTemplateContainer, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def delete_portal_template(request):
     """Delete selected template"""
     return delete_container_element(request, ignore_permission=True)
 
 
-@adapter_config(context=(ISite, IAdminLayer, PortalTemplateContainerTable), provides=IValues)
+@adapter_config(context=(ISite, IAdminLayer, PortalTemplateContainerTable),
+                provides=IValues)
 class PortalTemplateContainerValuesAdapter(ContextRequestViewAdapter):
     """Portal template container values adapter"""
 
@@ -159,7 +167,8 @@
         return ()
 
 
-@pagelet_config(name='portal-templates.html', context=ISite, layer=IPyAMSLayer, permission=MANAGE_TEMPLATE_PERMISSION)
+@pagelet_config(name='portal-templates.html',
+                context=ISite, layer=IPyAMSLayer, permission=MANAGE_TEMPLATE_PERMISSION)
 @implementer(IInnerPage)
 class PortalTemplateContainerView(AdminView, ContainerView):
     """Portal template container view"""
@@ -170,7 +179,8 @@
         super(PortalTemplateContainerView, self).__init__(context, request)
 
 
-@adapter_config(context=(ISite, IAdminLayer, PortalTemplateContainerView), provides=IPageHeader)
+@adapter_config(context=(ISite, IAdminLayer, PortalTemplateContainerView),
+                provides=IPageHeader)
 class PortalTemplateContainerHeaderAdapter(DefaultPageHeaderAdapter):
     """Portal template container header adapter"""
 
@@ -181,8 +191,10 @@
 # Templates container configuration views
 #
 
-@viewlet_config(name='templates-container-configuration.menu', context=ISite, layer=IAdminLayer,
-                manager=IPortalTemplateContainerMenu, permission=MANAGE_TEMPLATE_PERMISSION, weight=1)
+@viewlet_config(name='templates-container-configuration.menu',
+                context=ISite, layer=IAdminLayer,
+                manager=IPortalTemplateContainerMenu, weight=1,
+                permission=MANAGE_TEMPLATE_PERMISSION)
 class PortalTemplatesContainerPropertiesMenu(MenuItem):
     """Portal template container configuration menu"""
 
--- a/src/pyams_portal/zmi/layout.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/zmi/layout.py	Thu Aug 05 09:27:48 2021 +0200
@@ -65,19 +65,23 @@
         return _("Template management")
 
 
-@viewlet_config(name='template-properties.menu', context=IPortalTemplate, layer=IAdminLayer,
-                manager=IContentManagementMenu, permission=MANAGE_TEMPLATE_PERMISSION, weight=1)
-@viewletmanager_config(name='template-properties.menu', layer=IAdminLayer, provides=IPropertiesMenu)
+@viewlet_config(name='template-layout.menu',
+                context=IPortalTemplate, layer=IAdminLayer,
+                manager=IContentManagementMenu, weight=1,
+                permission=MANAGE_TEMPLATE_PERMISSION)
+@viewletmanager_config(name='template-layout.menu',
+                       layer=IAdminLayer,
+                       provides=IPropertiesMenu)
 @implementer(IPropertiesMenu)
 class PortalTemplatePropertiesMenu(MenuItem):
     """Portal template properties menu"""
 
-    label = _("Properties")
+    label = _("Layout")
     icon_class = 'fa-edit'
-    url = '#properties.html'
+    url = '#layout.html'
 
 
-@pagelet_config(name='properties.html', context=IPortalTemplate, layer=IPyAMSLayer,
+@pagelet_config(name='layout.html', context=IPortalTemplate, layer=IPyAMSLayer,
                 permission=MANAGE_TEMPLATE_PERMISSION)
 @template_config(template='templates/layout.pt', layer=IAdminLayer)
 @implementer(IInnerPage)
@@ -92,14 +96,11 @@
             page = IPortalPage(context)
             if page.use_local_template:
                 return _("Local template configuration")
-            else:
-                if page.template.name == LOCAL_TEMPLATE_NAME:
-                    return _("Inherited local template configuration")
-                else:
-                    translate = self.request.localizer.translate
-                    return translate(_("Shared template configuration ({0})")).format(page.template.name)
-        else:
-            return _("Template configuration")
+            if page.template.name == LOCAL_TEMPLATE_NAME:
+                return _("Inherited local template configuration")
+            translate = self.request.localizer.translate
+            return translate(_("Shared template configuration ({0})")).format(page.template.name)
+        return _("Template configuration")
 
     def get_template(self):
         return self.context
@@ -141,18 +142,16 @@
         portlet = self.get_portlet(name)
         if portlet is not None:
             return self.request.localizer.translate(portlet.label)
-        else:
-            return self.request.localizer.translate(_("{{ missing portlet }}"))
+        return self.request.localizer.translate(_("{{ missing portlet }}"))
 
     def get_portlet_preview(self, portlet_id):
         settings = self.portlet_configuration.get_portlet_configuration(portlet_id).settings
-        previewer = self.request.registry.queryMultiAdapter((self.get_context(), self.request, self, settings),
-                                                            IPortletPreviewer)
+        previewer = self.request.registry.queryMultiAdapter(
+            (self.get_context(), self.request, self, settings), IPortletPreviewer)
         if previewer is not None:
             previewer.update()
             return previewer.render()
-        else:
-            return ''
+        return ''
 
 
 #
@@ -170,7 +169,8 @@
     url = 'PyAMS_portal.template.addRow'
 
 
-@view_config(name='add-template-row.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='add-template-row.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def add_template_row(request):
     """Add template raw"""
@@ -178,7 +178,8 @@
     return {'row_id': config.add_row()}
 
 
-@view_config(name='set-template-row-order.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='set-template-row-order.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def set_template_row_order(request):
     """Set template rows order"""
@@ -188,7 +189,8 @@
     return {'status': 'success'}
 
 
-@view_config(name='delete-template-row.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='delete-template-row.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def delete_template_row(request):
     """Delete template row"""
@@ -201,9 +203,10 @@
 # Slots views
 #
 
-@viewlet_config(name='add-template-slot.menu', context=IPortalTemplate, layer=IAdminLayer,
-                view=PortalTemplateLayoutView, manager=IToolbarAddingMenu,
-                permission=MANAGE_TEMPLATE_PERMISSION, weight=2)
+@viewlet_config(name='add-template-slot.menu',
+                context=IPortalTemplate, layer=IAdminLayer, view=PortalTemplateLayoutView,
+                manager=IToolbarAddingMenu, weight=2,
+                permission=MANAGE_TEMPLATE_PERMISSION)
 class PortalTemplateSlotAddMenu(ToolbarMenuItem):
     """Portal template slot add menu"""
 
@@ -213,9 +216,11 @@
     modal_target = True
 
 
-@pagelet_config(name='add-template-slot.html', context=IPortalTemplate, layer=IPyAMSLayer,
+@pagelet_config(name='add-template-slot.html',
+                context=IPortalTemplate, layer=IPyAMSLayer,
                 permission=MANAGE_TEMPLATE_PERMISSION)
-@ajax_config(name='add-template-slot.json', context=IPortalTemplate, layer=IPyAMSLayer,
+@ajax_config(name='add-template-slot.json',
+             context=IPortalTemplate, layer=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, base=AJAXAddForm)
 class PortalTemplateSlotAddForm(AdminDialogAddForm):
     """Portal template slot add form"""
@@ -274,11 +279,12 @@
         else:
             if not 0 < row_id <= config.rows:
                 translate = event.form.request.localizer.translate
-                event.form.widgets.errors += (Invalid(translate(_("Row ID must be between 1 and {0}!")).format(
-                    config.rows)),)
+                event.form.widgets.errors += (Invalid(translate(
+                    _("Row ID must be between 1 and {0}!")).format(config.rows)),)
 
 
-@view_config(name='set-template-slot-order.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='set-template-slot-order.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def set_template_slot_order(request):
     """Set template slots order"""
@@ -290,7 +296,8 @@
     return {'status': 'success'}
 
 
-@view_config(name='get-slots-width.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='get-slots-width.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def get_template_slots_width(request):
     """Get template slots width"""
@@ -298,7 +305,8 @@
     return config.get_slots_width(request.params.get('device'))
 
 
-@view_config(name='set-slot-width.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='set-slot-width.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def set_template_slot_width(request):
     """Set template slot width"""
@@ -309,9 +317,11 @@
     return config.get_slots_width(request.params.get('device'))
 
 
-@pagelet_config(name='slot-properties.html', context=IPortalTemplate, layer=IPyAMSLayer,
+@pagelet_config(name='slot-properties.html',
+                context=IPortalTemplate, layer=IPyAMSLayer,
                 permission=MANAGE_TEMPLATE_PERMISSION)
-@ajax_config(name='slot-properties.json', context=IPortalTemplate, layer=IPyAMSLayer)
+@ajax_config(name='slot-properties.json',
+             context=IPortalTemplate, layer=IPyAMSLayer)
 class PortalTemplateSlotPropertiesEditForm(AdminDialogEditForm):
     """Slot properties edit form"""
 
@@ -363,7 +373,8 @@
             return super(self.__class__, self).get_ajax_output(changes)
 
 
-@view_config(name='delete-template-slot.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='delete-template-slot.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def delete_template_slot(request):
     """Delete template slot"""
@@ -376,16 +387,18 @@
 # Portlet views
 #
 
-@viewlet_config(name='add-template-portlet.divider', context=IPortalTemplate, layer=IAdminLayer,
-                view=PortalTemplateLayoutView, manager=IToolbarAddingMenu,
-                permission=MANAGE_TEMPLATE_PERMISSION, weight=10)
+@viewlet_config(name='add-template-portlet.divider',
+                context=IPortalTemplate, layer=IAdminLayer, view=PortalTemplateLayoutView,
+                manager=IToolbarAddingMenu, weight=10,
+                permission=MANAGE_TEMPLATE_PERMISSION)
 class PortalTemplateAddMenuDivider(ToolbarMenuDivider):
     """Portal template menu divider"""
 
 
-@viewlet_config(name='add-template-portlet.menu', context=IPortalTemplate, layer=IAdminLayer,
-                view=PortalTemplateLayoutView, manager=IToolbarAddingMenu,
-                permission=MANAGE_TEMPLATE_PERMISSION, weight=20)
+@viewlet_config(name='add-template-portlet.menu',
+                context=IPortalTemplate, layer=IAdminLayer, view=PortalTemplateLayoutView,
+                manager=IToolbarAddingMenu, weight=20,
+                permission=MANAGE_TEMPLATE_PERMISSION)
 class PortalTemplatePortletAddMenu(ToolbarMenuItem):
     """Portal template portlet add menu"""
 
@@ -395,9 +408,11 @@
     modal_target = True
 
 
-@pagelet_config(name='add-template-portlet.html', context=IPortalTemplate, layer=IPyAMSLayer,
+@pagelet_config(name='add-template-portlet.html',
+                context=IPortalTemplate, layer=IPyAMSLayer,
                 permission=MANAGE_TEMPLATE_PERMISSION)
-@ajax_config(name='add-template-portlet.json', context=IPortalTemplate, layer=IPyAMSLayer,
+@ajax_config(name='add-template-portlet.json',
+             context=IPortalTemplate, layer=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, base=AJAXAddForm)
 class PortalTemplatePortletAddForm(AdminDialogAddForm):
     """Portal template portlet add form"""
@@ -421,8 +436,8 @@
     def get_ajax_output(self, changes):
         configuration = IPortalPortletsConfiguration(self.context)
         settings = configuration.get_portlet_configuration(changes['portlet_id']).settings
-        previewer = self.request.registry.queryMultiAdapter((self.context, self.request, self, settings),
-                                                            IPortletPreviewer)
+        previewer = self.request.registry.queryMultiAdapter(
+            (self.context, self.request, self, settings), IPortletPreviewer)
         if previewer is not None:
             previewer.update()
             changes['preview'] = previewer.render()
@@ -433,7 +448,8 @@
         }
 
 
-@view_config(name='drag-template-portlet.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='drag-template-portlet.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def drag_template_portlet(request):
     """Drag portlet icon to slot"""
@@ -443,8 +459,8 @@
     slot_name = request.params.get('slot_name')
     changes = tmpl_config.add_portlet(portlet_name, slot_name)
     settings = portlets_config.get_portlet_configuration(changes['portlet_id']).settings
-    previewer = request.registry.queryMultiAdapter((request.context, request, request, settings),
-                                                   IPortletPreviewer)
+    previewer = request.registry.queryMultiAdapter(
+        (request.context, request, request, settings), IPortletPreviewer)
     if previewer is not None:
         previewer.update()
         changes['preview'] = previewer.render()
@@ -456,7 +472,8 @@
     }
 
 
-@view_config(name='set-template-portlet-order.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='set-template-portlet-order.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def set_template_portlet_order(request):
     """Set template portlet order"""
@@ -480,11 +497,12 @@
         request = self.request
         request.registry.notify(PageletCreatedEvent(self))
         portlet_id = int(request.params.get('{0}widgets.portlet_id'.format(self.prefix)))
-        portlet_config = IPortalPortletsConfiguration(self.context).get_portlet_configuration(portlet_id)
+        portlet_config = IPortalPortletsConfiguration(self.context) \
+            .get_portlet_configuration(portlet_id)
         if portlet_config is None:
             raise NotFound()
-        editor = self.request.registry.queryMultiAdapter((portlet_config.editor_settings, request),
-                                                         IPagelet, name='properties.html')
+        editor = self.request.registry.queryMultiAdapter(
+            (portlet_config.editor_settings, request), IPagelet, name='properties.html')
         if editor is None:
             raise NotFound()
         request.registry.notify(PageletCreatedEvent(editor))
@@ -503,7 +521,8 @@
         request.registry.notify(PageletCreatedEvent(self))
         # load portlet config
         portlet_id = int(request.params.get('{0}widgets.portlet_id'.format(self.prefix)))
-        portlet_config = IPortalPortletsConfiguration(self.context).get_portlet_configuration(portlet_id)
+        portlet_config = IPortalPortletsConfiguration(self.context) \
+            .get_portlet_configuration(portlet_id)
         if portlet_config is None:
             raise NotFound()
         # check inheritance
@@ -552,7 +571,8 @@
         return changes
 
 
-@view_config(name='delete-template-portlet.json', context=IPortalTemplate, request_type=IPyAMSLayer,
+@view_config(name='delete-template-portlet.json',
+             context=IPortalTemplate, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 def delete_template_portlet(request):
     """Delete template portlet"""
--- a/src/pyams_portal/zmi/page.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/zmi/page.py	Thu Aug 05 09:27:48 2021 +0200
@@ -95,6 +95,11 @@
     def getContent(self):
         return IPortalPage(self.context)
 
+    @property
+    def template_css_class(self):
+        template = IPortalPage(self.context).local_template
+        return template.css_class if template is not None else ''
+
     def updateWidgets(self, prefix=None):
         super().updateWidgets(prefix)
         shared_template = self.widgets.get('shared_template')
@@ -116,7 +121,12 @@
                 content.use_local_template = False
             elif template_mode == TEMPLATE_LOCAL_MODE:
                 content.use_local_template = True
-        return {IPortalPage: ('inherit_parent', 'use_local_template', 'shared_template')}
+                template = IPortalPage(self.context).local_template
+                if template is not None:
+                    template.css_class = self.request.params.get('template_css_class', '')
+        return {
+            IPortalPage: ('inherit_parent', 'use_local_template', 'shared_template')
+        }
 
     def get_ajax_output(self, changes):
         output = super(self.__class__, self).get_ajax_output(changes)
@@ -137,23 +147,27 @@
     form = event.form
     if not form.getContent().can_inherit:
         data = event.data
-        if (form.request.params.get('template_mode') == TEMPLATE_SHARED_MODE) and not data.get('shared_template'):
+        if (form.request.params.get('template_mode') == TEMPLATE_SHARED_MODE) and \
+                not data.get('shared_template'):
             form.widgets.errors += (Invalid(_("You must select which shared template to use!")),)
 
 
-@adapter_config(context=(Interface, IPyAMSLayer, PortalContextTemplatePropertiesEditForm), provides=IPageHeader)
+@adapter_config(context=(Interface, IPyAMSLayer, PortalContextTemplatePropertiesEditForm),
+                provides=IPageHeader)
 class PortalContextPropertiesEditFormHeaderAdapter(PropertiesEditFormHeaderAdapter):
     """Portal context template properties edit form header adapter"""
 
     icon_class = 'fa fa-fw fa-columns'
 
 
-@adapter_config(context=(IPortalContext, IAdminLayer, PortalContextTemplatePropertiesEditForm), provides=IFormHelp)
+@adapter_config(context=(IPortalContext, IAdminLayer, PortalContextTemplatePropertiesEditForm),
+                provides=IFormHelp)
 class PortalContextPropertiesEditFormHelpAdapter(FormHelp):
     """Portal context properties edit form help adapter"""
 
     message = _("If you choose a shared template, you can only adjust settings of "
-                "each portlet individually but can't change portlets list or page configuration.\n"
+                "each portlet individually but can't change portlets list or page "
+                "configuration.\n"
                 "If you use a local template, you can define a whole custom "
                 "configuration but the template definition can't be reused anywhere...""")
     message_format = 'text'
@@ -163,8 +177,10 @@
 # Portal context template configuration
 #
 
-@viewlet_config(name='template-config.menu', context=IPortalContext, layer=IAdminLayer,
-                manager=IPortalContextTemplatePropertiesMenu, permission=MANAGE_TEMPLATE_PERMISSION, weight=50)
+@viewlet_config(name='template-config.menu',
+                context=IPortalContext, layer=IAdminLayer,
+                manager=IPortalContextTemplatePropertiesMenu, weight=50,
+                permission=MANAGE_TEMPLATE_PERMISSION)
 class PortalContextTemplateConfigMenu(MenuItem):
     """Portal context template configuration menu"""
 
@@ -186,7 +202,8 @@
             return super(PortalContextTemplateConfigMenu, self).get_url()
 
 
-@pagelet_config(name='template-config.html', context=IPortalContext, layer=IPyAMSLayer,
+@pagelet_config(name='template-config.html',
+                context=IPortalContext, layer=IPyAMSLayer,
                 permission=MANAGE_TEMPLATE_PERMISSION)
 class PortalContextTemplateLayoutView(PortalTemplateLayoutView):
     """Portal context template configuration view"""
@@ -201,7 +218,8 @@
         return self.request.has_permission(MANAGE_TEMPLATE_PERMISSION)
 
 
-@adapter_config(context=(IPortalContext, IAdminLayer, PortalContextTemplateLayoutView), provides=IPageHeader)
+@adapter_config(context=(IPortalContext, IAdminLayer, PortalContextTemplateLayoutView),
+                provides=IPageHeader)
 class PortalContextTemplateLayoutHeaderAdapter(PortalTemplateHeaderAdapter):
     """Portal context template configuration header adapter"""
 
@@ -210,7 +228,8 @@
 # Template management views
 #
 
-@view_config(name='get-slots-width.json', context=IPortalContext, request_type=IPyAMSLayer,
+@view_config(name='get-slots-width.json',
+             context=IPortalContext, request_type=IPyAMSLayer,
              permission=VIEW_SYSTEM_PERMISSION, renderer='json', xhr=True)
 def get_template_slots_width(request):
     """Get template slots width"""
@@ -218,13 +237,15 @@
     return config.get_slots_width(request.params.get('device'))
 
 
-@view_config(name='portlet-properties.html', context=IPortalContext, request_type=IPyAMSLayer,
+@view_config(name='portlet-properties.html',
+             context=IPortalContext, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION)
 class PortalContextTemplatePortletEditForm(PortalTemplatePortletEditForm):
     """Portal context template portlet edit form"""
 
 
-@view_config(name='portlet-properties.json', context=IPortalContext, request_type=IPyAMSLayer,
+@view_config(name='portlet-properties.json',
+             context=IPortalContext, request_type=IPyAMSLayer,
              permission=MANAGE_TEMPLATE_PERMISSION, renderer='json', xhr=True)
 class PortalContextTemplatePortletAJAXEditForm(PortalTemplatePortletAJAXEditForm):
     """Portal context template portlet edit form, JSON renderer"""
--- a/src/pyams_portal/zmi/portlet.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/zmi/portlet.py	Thu Aug 05 09:27:48 2021 +0200
@@ -10,25 +10,17 @@
 # FOR A PARTICULAR PURPOSE.
 #
 
-__docformat__ = 'restructuredtext'
-
-
-# import standard library
-
 from pyramid.decorator import reify
 from z3c.form import field
 from z3c.form.interfaces import INPUT_MODE
-from zope.interface import implementer, Interface
+from zope.interface import Interface, implementer
 
-# import packages
 from pyams_form.form import ajax_config
 from pyams_form.help import FormHelp
-# import interfaces
-from pyams_form.interfaces.form import IFormManager, IInnerTabForm, IFormHelp
+from pyams_form.interfaces.form import IFormHelp, IFormManager, IInnerTabForm
 from pyams_pagelet.pagelet import pagelet_config
-from pyams_portal import _
-from pyams_portal.interfaces import IPortlet, IPortalTemplate, IPortalPage, MANAGE_TEMPLATE_PERMISSION, \
-    IPortletSettings, IPortletRendererSettings, LOCAL_TEMPLATE_NAME
+from pyams_portal.interfaces import IPortalPage, IPortalTemplate, IPortlet, \
+    IPortletRendererSettings, IPortletSettings, LOCAL_TEMPLATE_NAME, MANAGE_TEMPLATE_PERMISSION
 from pyams_portal.zmi.widget import PortletRendererFieldWidget
 from pyams_skin.event import get_json_widget_refresh_event
 from pyams_skin.layer import IPyAMSLayer
@@ -40,6 +32,11 @@
 from pyams_zmi.layer import IAdminLayer
 
 
+__docformat__ = 'restructuredtext'
+
+from pyams_portal import _
+
+
 @template_config(template='templates/portlet.pt', layer=IPyAMSLayer)
 class PortletSettingsEditor(AdminDialogEditForm):
     """Portlet settings edit form"""
@@ -53,10 +50,11 @@
         if not IPortalTemplate.providedBy(parent):
             parent = IPortalPage(parent).template
         if parent.name == LOCAL_TEMPLATE_NAME:
-            return translate(_("Local portal template - {0}")).format(translate(self.portlet.label))
+            return translate(_("Local portal template - {0}")).format(
+                translate(self.portlet.label))
         else:
-            return translate(_("« {0} »  portal template - {1}")).format(parent.name,
-                                                                         translate(self.portlet.label))
+            return translate(_("« {0} »  portal template - {1}")).format(
+                parent.name, translate(self.portlet.label))
 
     legend = _("Edit portlet settings")
     dialog_class = 'modal-large'
@@ -104,11 +102,14 @@
             return None
         return FormHelp.__new__(cls)
 
-    message = _("""WARNING: Portlet properties are saved automatically when changing inherit mode!!""")
+    message = _("WARNING: Portlet properties are saved "
+                "automatically when changing inherit mode!!")
     message_format = 'rest'
 
 
-@adapter_config(name='properties', context=(Interface, IPyAMSLayer, PortletSettingsEditor), provides=IInnerTabForm)
+@adapter_config(name='properties',
+                context=(Interface, IPyAMSLayer, PortletSettingsEditor),
+                provides=IInnerTabForm)
 class PortletSettingsPropertiesEditor(InnerAdminEditForm):
     """Portlet settings properties editor"""
 
@@ -149,11 +150,13 @@
                     'close_form': False
                 })
                 output.setdefault('events', []).append(
-                    get_json_widget_refresh_event(self.context, self.request, get_form_factory, 'renderer'))
+                    get_json_widget_refresh_event(self.context, self.request,
+                                                  get_form_factory, 'renderer'))
                 output['smallbox'] = {
                     'status': 'info',
-                    'message': self.request.localizer.translate(_("You changed renderer selection. Don't omit to "
-                                                                  "update new renderer properties...")),
+                    'message': self.request.localizer.translate(
+                        _("You changed renderer selection. "
+                          "Don't omit to update new renderer properties...")),
                     'timeout': 5000
                 }
         return output
@@ -198,22 +201,22 @@
         if self.manager is not None:
             self.manager.update()
         else:
-            super(PortletRendererPropertiesEditForm, self).update()
+            super().update()
 
     def updateWidgets(self, prefix=None):
         if self.manager is not None:
             self.manager.updateWidgets(prefix)
         else:
-            super(PortletRendererPropertiesEditForm, self).updateWidgets(prefix)
+            super().updateWidgets(prefix)
 
     def updateActions(self):
         if self.manager is not None:
             self.manager.updateActions()
         else:
-            super(PortletRendererPropertiesEditForm, self).updateActions()
+            super().updateActions()
 
     def updateGroups(self):
         if self.manager is not None:
             self.manager.updateGroups()
         else:
-            super(PortletRendererPropertiesEditForm, self).updateGroups()
+            super().updateGroups()
--- a/src/pyams_portal/zmi/template.py	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/zmi/template.py	Thu Aug 05 09:27:48 2021 +0200
@@ -33,6 +33,7 @@
 from pyams_skin.page import DefaultPageHeaderAdapter
 from pyams_skin.table import DefaultElementEditorAdapter
 from pyams_skin.viewlet.breadcrumb import BreadcrumbAdminLayerItem
+from pyams_skin.viewlet.menu import MenuItem
 from pyams_skin.viewlet.toolbar import ToolbarAction, ToolbarMenuDivider, ToolbarMenuItem
 from pyams_utils.adapter import ContextRequestAdapter, adapter_config
 from pyams_utils.registry import get_utility, query_utility
@@ -41,6 +42,7 @@
 from pyams_utils.url import absolute_url
 from pyams_viewlet.viewlet import viewlet_config
 from pyams_zmi.form import AdminDialogAddForm, AdminDialogEditForm
+from pyams_zmi.interfaces.menu import IPropertiesMenu
 from pyams_zmi.layer import IAdminLayer
 
 
@@ -82,9 +84,11 @@
             return self.context.name
         context = get_parent(self.context, IPortalContext)
         if context is not None:
-            adapter = self.request.registry.queryMultiAdapter((context, self.request), ITableElementName)
+            adapter = self.request.registry.queryMultiAdapter((context, self.request),
+                                                              ITableElementName)
             if adapter is not None:
-                return self.request.localizer.translate(_("{0} (local template)")).format(adapter.name)
+                return self.request.localizer.translate(_("{0} (local template)")).format(
+                    adapter.name)
         return '--'
 
 
@@ -112,7 +116,8 @@
         # check for portal context
         context = get_parent(self.context, IPortalContext)
         if context is not None:
-            adapter = self.request.registry.queryMultiAdapter((context, self.request, self.view), IContentTitle)
+            adapter = self.request.registry.queryMultiAdapter((context, self.request, self.view),
+                                                              IContentTitle)
             if adapter is None:
                 adapter = IContentTitle(context, None)
             if adapter is not None:
@@ -120,7 +125,7 @@
 
 
 #
-# Template views
+# Template add views
 #
 
 @viewlet_config(name='add-portal-template.action', context=ISite, layer=IAdminLayer,
@@ -144,7 +149,7 @@
     legend = _("Add shared template")
     icon_css_class = 'fa fa-fw fa-columns'
 
-    fields = field.Fields(IPortalTemplate)
+    fields = field.Fields(IPortalTemplate).select('name')
     edit_permission = MANAGE_TEMPLATE_PERMISSION
 
     def create(self, data):
@@ -168,58 +173,47 @@
 
 
 #
-# Template renaming form
+# Template properties form
 #
 
-@viewlet_config(name='rename.menu', context=IPortalTemplate, layer=IPyAMSLayer,
-                view=PortalTemplateLayoutView, manager=IContextActions,
-                permission=MANAGE_TEMPLATE_PERMISSION, weight=100)
-class PortalTemplateRenameMenu(ToolbarMenuItem):
-    """Portal template rename menu item"""
+@viewlet_config(name='properties.menu',
+                context=IPortalTemplate, layer=IAdminLayer,
+                manager=IPropertiesMenu, weight=10,
+                permission=MANAGE_TEMPLATE_PERMISSION)
+class PortalTemplatePropertiesMenu(MenuItem):
+    """Portal template properties menu item"""
 
     def __new__(cls, context, request, view, manager):
         container = get_parent(context, IPortalTemplateContainer)
         if container is None:
             return None
-        return ToolbarMenuDivider.__new__(cls)
+        return MenuItem.__new__(cls)
 
-    label = _("Rename template...")
+    label = _("Template properties...")
     label_css_class = 'fa fa-fw fa-edit'
 
-    url = 'rename.html'
+    url = 'properties.html'
     modal_target = True
 
 
-class IPortalTemplateRenameButtons(Interface):
-    """Portal template rename form buttons"""
-
-    close = CloseButton(name='close', title=_("Cancel"))
-    rename = button.Button(name='rename', title=_("Rename template"))
-
+@pagelet_config(name='properties.html',
+                context=IPortalTemplate, layer=IPyAMSLayer,
+                permission=MANAGE_TEMPLATE_PERMISSION)
+@ajax_config(name='properties.json', context=IPortalTemplate, layer=IPyAMSLayer)
+class PortalTemplatePropertiesEditForm(AdminDialogEditForm):
+    """Portal template properties edit form"""
 
-@pagelet_config(name='rename.html', context=IPortalTemplate, layer=IPyAMSLayer,
-                permission=MANAGE_TEMPLATE_PERMISSION)
-@ajax_config(name='rename.json', context=IPortalTemplate, layer=IPyAMSLayer)
-class PortalTemplateRenameForm(AdminDialogEditForm):
-    """Portal template rename form"""
-
-    legend = _("Rename template")
+    legend = _("Template properties")
     icon_css_class = 'fa fa-fw fa-edit'
 
-    fields = field.Fields(IPortalTemplate).select('name')
-    buttons = button.Buttons(IPortalTemplateRenameButtons)
+    fields = field.Fields(IPortalTemplate).select('name', 'css_class')
 
     edit_permission = MANAGE_TEMPLATE_PERMISSION
 
     _renamed = False
 
-    def updateActions(self):
-        super(PortalTemplateRenameForm, self).updateActions()
-        if 'rename' in self.actions:
-            self.actions['rename'].addClass('btn-primary')
-
     def update_content(self, content, data):
-        changes = super(PortalTemplateRenameForm, self).update_content(content, data)
+        changes = super().update_content(content, data)
         if changes:
             data = data.get(self, data)
             old_name = content.__name__
@@ -237,8 +231,7 @@
                 'status': 'redirect',
                 'location': absolute_url(self.getContent(), self.request, 'admin#properties.html')
             }
-        else:
-            return super(PortalTemplateRenameForm, self).get_ajax_output(changes)
+        return super().get_ajax_output(changes)
 
 
 #
--- a/src/pyams_portal/zmi/templates/template-properties.pt	Fri Mar 26 16:32:52 2021 +0100
+++ b/src/pyams_portal/zmi/templates/template-properties.pt	Thu Aug 05 09:27:48 2021 +0200
@@ -82,6 +82,20 @@
 							<i></i><span i18n:translate="">Use custom local template</span>
 						</label>
 					</div>
+					<div class="form-group">
+						<div>
+							<div class="control-label col-md-3">
+								<span i18n:translate="">Custom CSS class</span>
+							</div>
+							<div class="col-md-3">
+								<div class="input">
+									<input type="text"
+										   name="template_css_class"
+										   value="${view.template_css_class}" />
+								</div>
+							</div>
+						</div>
+					</div>
 				</fieldset>
 			</div>
 			<footer tal:condition="view.actions and (view.is_dialog or (view.mode == 'input'))">