35. Dexterity Types III: Python

Without sponsors, a conference would be hard to finance! Plus it is a good opportunity for Plone companies to advertise their services. But sponsors want to be displayed in a nice way according to the size of their sponsorship.

In this part we will:

  • create the content type sponsor that has a Python schema,

  • create a viewlet that shows the sponsor logos sorted by sponsoring level.

The topics we cover are:

  • Python schema for Dexterity

  • schema hint and directives

  • field permissions

  • image scales

  • caching

35.1. The Python schema

First we create the schema for the new type. Instead of XML, we use Python this time. In chapter Return to Dexterity: Moving contenttypes into Code you already created a folder content with an empty __init__.py in it. We don't need to register that folder in configure.zcml since we don't need a content/configure.zcml (at least not yet).

Now add a new file content/sponsor.py.

 1# -*- coding: utf-8 -*-
 2from plone.app.textfield import RichText
 3from plone.autoform import directives
 4from plone.namedfile import field as namedfile
 5from plone.supermodel import model
 6from plone.supermodel.directives import fieldset
 7from ploneconf.site import _
 8from z3c.form.browser.radio import RadioFieldWidget
 9from zope import schema
10from zope.schema.vocabulary import SimpleTerm
11from zope.schema.vocabulary import SimpleVocabulary
12
13
14LevelVocabulary = SimpleVocabulary(
15    [SimpleTerm(value=u'platinum', title=_(u'Platinum Sponsor')),
16     SimpleTerm(value=u'gold', title=_(u'Gold Sponsor')),
17     SimpleTerm(value=u'silver', title=_(u'Silver Sponsor')),
18     SimpleTerm(value=u'bronze', title=_(u'Bronze Sponsor'))]
19    )
20
21
22class ISponsor(model.Schema):
23    """Dexterity Schema for Sponsors
24    """
25
26    directives.widget(level=RadioFieldWidget)
27    level = schema.Choice(
28        title=_(u'Sponsoring Level'),
29        vocabulary=LevelVocabulary,
30        required=True
31    )
32
33    text = RichText(
34        title=_(u'Text'),
35        required=False
36    )
37
38    url = schema.URI(
39        title=_(u'Link'),
40        required=False
41    )
42
43    fieldset('Images', fields=['logo', 'advertisement'])
44    logo = namedfile.NamedBlobImage(
45        title=_(u'Logo'),
46        required=False,
47    )
48
49    advertisement = namedfile.NamedBlobImage(
50        title=_(u'Advertisement (Gold-sponsors and above)'),
51        required=False,
52    )
53
54    directives.read_permission(notes='cmf.ManagePortal')
55    directives.write_permission(notes='cmf.ManagePortal')
56    notes = RichText(
57        title=_(u'Secret Notes (only for site-admins)'),
58        required=False
59    )

Some things are notable here:

  • The fields in the schema are mostly from zope.schema. A reference of available fields is at https://docs.plone.org/external/plone.app.dexterity/docs/reference/fields.html

  • In directives.widget(level=RadioFieldWidget) we change the default widget for a Choice field from a dropdown to radio-boxes. An incomplete reference of available widgets is at https://docs.plone.org/external/plone.app.dexterity/docs/reference/widgets.html

  • LevelVocabulary is used to create the options used in the field level. This way we could easily translate the displayed value.

  • fieldset('Images', fields=['logo', 'advertisement']) moves the two image fields to another tab.

  • directives.read_permission(...) sets the read and write permission for the field notes to users who can add new members. Usually this permission is only granted to Site Administrators and Managers. We use it to store information that should not be publicly visible. Please note that obj.notes is still accessible in templates and Python. Only using the widget (like we do in the view later) checks for the permission.

See also

See the chapter Dexterity: Reference for a reference of all field-types and directives you can use in dexterity.

35.2. The Factory Type Information, or FTI

Next, we create the factory type information ("FTI") for the new type in profiles/default/types/sponsor.xml

 1<?xml version="1.0"?>
 2<object name="sponsor" meta_type="Dexterity FTI" i18n:domain="plone"
 3   xmlns:i18n="http://xml.zope.org/namespaces/i18n">
 4 <property name="title" i18n:translate="">Sponsor</property>
 5 <property name="description" i18n:translate=""></property>
 6 <property name="icon_expr">string:${portal_url}/document_icon.png</property>
 7 <property name="factory">sponsor</property>
 8 <property name="add_view_expr">string:${folder_url}/++add++sponsor</property>
 9 <property name="link_target"></property>
10 <property name="immediate_view">view</property>
11 <property name="global_allow">True</property>
12 <property name="filter_content_types">True</property>
13 <property name="allowed_content_types"/>
14 <property name="allow_discussion">False</property>
15 <property name="default_view">view</property>
16 <property name="view_methods">
17  <element value="view"/>
18 </property>
19 <property name="default_view_fallback">False</property>
20 <property name="add_permission">cmf.AddPortalContent</property>
21 <property name="klass">plone.dexterity.content.Container</property>
22 <property name="behaviors">
23  <element value="plone.dublincore"/>
24  <element value="plone.namefromtitle"/>
25 </property>
26 <property name="schema">ploneconf.site.content.sponsor.ISponsor</property>
27 <property name="model_source"></property>
28 <property name="model_file"></property>
29 <property name="schema_policy">dexterity</property>
30 <alias from="(Default)" to="(dynamic view)"/>
31 <alias from="edit" to="@@edit"/>
32 <alias from="sharing" to="@@sharing"/>
33 <alias from="view" to="(selected layout)"/>
34 <action title="View" action_id="view" category="object" condition_expr=""
35    description="" icon_expr="" link_target="" url_expr="string:${object_url}"
36    visible="True">
37  <permission value="View"/>
38 </action>
39 <action title="Edit" action_id="edit" category="object" condition_expr=""
40    description="" icon_expr="" link_target=""
41    url_expr="string:${object_url}/edit" visible="True">
42  <permission value="Modify portal content"/>
43 </action>
44</object>

Then we register the FTI in profiles/default/types.xml

1<?xml version="1.0"?>
2<object name="portal_types" meta_type="Plone Types Tool">
3 <property name="title">Controls the available contenttypes in your portal</property>
4 <object name="talk" meta_type="Dexterity FTI"/>
5 <object name="sponsor" meta_type="Dexterity FTI"/>
6 <!-- -*- more types can be added here -*- -->
7</object>

After reinstalling our package we can create the new type.

35.2.1. Exercise 1

Sponsors are containers but they don't need to be. Turn them into items by changing their class to plone.dexterity.content.Item.

Solution

Simply modify the property klass in the FTI and reinstall.

1<property name="klass">plone.dexterity.content.Item</property>

35.3. The view

We use the default view provided by Dexterity for testing since we will only display the sponsors in a viewlet and not in their own page.

Note

If we really want a custom view for sponsors it could look like this.

 1<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en"
 2      metal:use-macro="context/main_template/macros/master"
 3      i18n:domain="ploneconf.site">
 4<body>
 5  <metal:content-core fill-slot="content-core">
 6    <h3 tal:content="structure view/w/level/render">
 7      Level
 8    </h3>
 9
10    <div tal:content="structure view/w/text/render">
11      Text
12    </div>
13
14    <div class="newsImageContainer">
15      <a tal:attributes="href context/url">
16        <img tal:condition="python:getattr(context, 'logo', None)"
17             tal:attributes="src string:${context/absolute_url}/@@images/logo/preview" />
18      </a>
19    </div>
20
21    <div>
22      <a tal:attributes="href context/url">
23        Website
24      </a>
25
26      <img tal:condition="python:getattr(context, 'advertisement', None)"
27           tal:attributes="src string:${context/absolute_url}/@@images/advertisement/preview" />
28
29      <div tal:condition="python: 'notes' in view.w"
30           tal:content="structure view/w/notes/render">
31        Notes
32      </div>
33
34    </div>
35  </metal:content-core>
36</body>
37</html>

Note how we handle the field with special permissions: tal:condition="python: 'notes' in view.w" checks if the convenience-dictionary w (provided by the base class DefaultView) holds the widget for the field notes. If the current user does not have the permission cmf.ManagePortal it will be omitted from the dictionary and get an error since notes would not be a key in w. By first checking if it's missing we work around that.

35.4. The viewlet

Instead of writing a view you will have to display the sponsors at the bottom of the website in a viewlet.

Register the viewlet in browser/configure.zcml

1<browser:viewlet
2    name="sponsorsviewlet"
3    manager="plone.app.layout.viewlets.interfaces.IPortalFooter"
4    for="*"
5    layer="..interfaces.IPloneconfSiteLayer"
6    class=".viewlets.SponsorsViewlet"
7    template="templates/sponsors_viewlet.pt"
8    permission="zope2.View"
9    />

Add the viewlet class in browser/viewlets.py

 1# -*- coding: utf-8 -*-
 2from collections import OrderedDict
 3from plone import api
 4from plone.app.layout.viewlets.common import ViewletBase
 5from plone.memoize import ram
 6from ploneconf.site.behaviors.featured import IFeatured
 7from ploneconf.site.content.sponsor import LevelVocabulary
 8from random import shuffle
 9from time import time
10
11
12class FeaturedViewlet(ViewletBase):
13
14    def is_featured(self):
15        adapted = IFeatured(self.context)
16        return adapted.featured
17
18
19class SponsorsViewlet(ViewletBase):
20
21    @ram.cache(lambda *args: time() // (60 * 60))
22    def _sponsors(self):
23        results = []
24        for brain in api.content.find(portal_type='sponsor'):
25            obj = brain.getObject()
26            scales = api.content.get_view(
27                name='images',
28                context=obj,
29                request=self.request)
30            scale = scales.scale(
31                'logo',
32                width=200,
33                height=80,
34                direction='down')
35            tag = scale.tag() if scale else None
36            if not tag:
37                # only display sponsors with a logo
38                continue
39            results.append({
40                'title': obj.title,
41                'description': obj.description,
42                'tag': tag,
43                'url': obj.url or obj.absolute_url(),
44                'level': obj.level
45            })
46        return results
47
48    def sponsors(self):
49        sponsors = self._sponsors()
50        if not sponsors:
51            return
52        results = OrderedDict()
53        levels = [i.value for i in LevelVocabulary]
54        for level in levels:
55            level_sponsors = []
56            for sponsor in sponsors:
57                if level == sponsor['level']:
58                    level_sponsors.append(sponsor)
59            if not level_sponsors:
60                continue
61            shuffle(level_sponsors)
62            results[level] = level_sponsors
63        return results
  • _sponsors() returns a list of dictionaries containing all necessary info about sponsors.

  • We create the complete img tag using a custom scale (200x80) using the view images from plone.namedfile. This actually scales the logos and saves them as new blobs.

  • In sponsors() we return an ordered dictionary of randomized lists of dicts (containing the information on sponsors). The order is by sponsor-level since we want the platinum sponsors on top and the bronze sponsors at the bottom. The randomization is for fairness among equal sponsors.

_sponsors() is cached for an hour using plone.memoize. This way we don't need to keep all sponsor objects in memory all the time. But we'd have to wait for up to an hour until changes will be visible.

Instead we should cache until one of the sponsors is modified by using a callable _sponsors_cachekey() that returns a number that changes when a sponsor is modified.

...
def _sponsors_cachekey(method, self):
    brains = api.content.find(portal_type='sponsor')
    cachekey = sum([int(i.modified) for i in brains])
    return cachekey

@ram.cache(_sponsors_cachekey)
def _sponsors(self):
    catalog = api.portal.get_tool('portal_catalog')
...

35.5. The template for the viewlet

Add the template browser/templates/sponsors_viewlet.pt

 1<div metal:define-macro="portal_sponsorbox"
 2     i18n:domain="ploneconf.site">
 3    <div id="portal-sponsorbox" class="container"
 4         tal:define="sponsors view/sponsors;"
 5         tal:condition="sponsors">
 6        <div class="row">
 7            <h2>We ❤ our sponsors</h2>
 8        </div>
 9        <div tal:repeat="level sponsors"
10             tal:attributes="id python:'level-' + level"
11             class="row">
12            <h3 tal:content="python: level.capitalize()">
13                Gold
14            </h3>
15            <tal:images tal:define="items python:sponsors[level];"
16                        tal:repeat="item items">
17                <div class="sponsor">
18                    <a href=""
19                       tal:attributes="href python:item['url'];
20                                       title python:item['title'];">
21                        <img tal:replace="structure python:item['tag']" />
22                    </a>
23                </div>
24            </tal:images>
25        </div>
26    </div>
27</div>

You can now add some CSS in browser/static/ploneconf.css to make it look OK.

.sponsor {
    display: inline-block;
    margin: 0 1em 1em 0;
}

.sponsor:hover {
    box-shadow: 0 0 8px #000;
    -moz-box-shadow: 0 0 8px #000;
    -webkit-box-shadow: 0 0 8px #000;
}

Result:

The result of the newly created content type.

The result of the newly created content type.

35.5.1. Exercise 2

Turn the content type Speaker from Exercise 2 of the first chapter on Dexterity into a Python-based type.

When we're done, it should have the following fields:

  • title

  • email

  • homepage

  • biography

  • company

  • twitter_name

  • irc_name

  • image

Do not use the IBasic or IDublinCore behavior to add title and description. Instead add your own field title and give it the title Name.

Solution

 1# -*- coding: utf-8 -*-
 2from plone.app.textfield import RichText
 3from plone.app.vocabularies.catalog import CatalogSource
 4from plone.autoform import directives
 5from plone.namedfile import field as namedfile
 6from plone.supermodel import model
 7from ploneconf.site import _
 8from z3c.relationfield.schema import RelationChoice
 9from z3c.relationfield.schema import RelationList
10from zope import schema
11
12
13class ISpeaker(model.Schema):
14    """Dexterity-Schema for Speaker
15    """
16
17    title = schema.TextLine(
18        title=_(u'Name'),
19    )
20
21    email = schema.TextLine(
22        title=_(u'E-Mail'),
23        required=False,
24    )
25
26    homepage = schema.URI(
27        title=_(u'Homepage'),
28        required=False,
29    )
30
31    biography = RichText(
32        title=_(u'Biography'),
33        required=False,
34    )
35
36    company = schema.TextLine(
37        title=_(u'Company'),
38        required=False,
39    )
40
41    twitter_name = schema.TextLine(
42        title=_(u'Twitter-Name'),
43        required=False,
44    )
45
46    irc_name = schema.TextLine(
47        title=_(u'IRC-Name'),
48        required=False,
49    )
50
51    image = namedfile.NamedBlobImage(
52        title=_(u'Image'),
53        required=False,
54    )

Register the type in profiles/default/types.xml

1<?xml version="1.0"?>
2<object name="portal_types" meta_type="Plone Types Tool">
3 <property name="title">Controls the available contenttypes in your portal</property>
4 <object name="talk" meta_type="Dexterity FTI"/>
5 <object name="sponsor" meta_type="Dexterity FTI"/>
6 <object name="speaker" meta_type="Dexterity FTI"/>
7 <!-- -*- more types can be added here -*- -->
8</object>

The FTI goes in profiles/default/types/speaker.xml. Again we use Item as the base-class:

 1<?xml version="1.0"?>
 2<object name="speaker" meta_type="Dexterity FTI" i18n:domain="plone"
 3   xmlns:i18n="http://xml.zope.org/namespaces/i18n">
 4 <property name="title" i18n:translate="">Speaker</property>
 5 <property name="description" i18n:translate=""></property>
 6 <property name="icon_expr"></property>
 7 <property name="factory">speaker</property>
 8 <property name="add_view_expr">string:${folder_url}/++add++speaker</property>
 9 <property name="link_target"></property>
10 <property name="immediate_view">view</property>
11 <property name="global_allow">True</property>
12 <property name="filter_content_types">True</property>
13 <property name="allowed_content_types"/>
14 <property name="allow_discussion">False</property>
15 <property name="default_view">view</property>
16 <property name="view_methods">
17  <element value="view"/>
18 </property>
19 <property name="default_view_fallback">False</property>
20 <property name="add_permission">cmf.AddPortalContent</property>
21 <property name="klass">plone.dexterity.content.Item</property>
22 <property name="schema">ploneconf.site.content.speaker.ISpeaker</property>
23 <property name="model_source"></property>
24 <property name="model_file"></property>
25 <property name="behaviors">
26  <element value="plone.namefromtitle"/>
27 </property>
28 <property name="schema_policy">dexterity</property>
29 <alias from="(Default)" to="(dynamic view)"/>
30 <alias from="edit" to="@@edit"/>
31 <alias from="sharing" to="@@sharing"/>
32 <alias from="view" to="(selected layout)"/>
33 <action title="View" action_id="view" category="object" condition_expr=""
34    description="" icon_expr="" link_target="" url_expr="string:${object_url}"
35    visible="True">
36  <permission value="View"/>
37 </action>
38 <action title="Edit" action_id="edit" category="object" condition_expr=""
39    description="" icon_expr="" link_target=""
40    url_expr="string:${object_url}/edit" visible="True">
41  <permission value="Modify portal content"/>
42 </action>
43</object>

After reinstalling the package the new type is usable.

35.5.2. Exercise 3

This is more of a Python exercise. The gold and bronze sponsors should also have a bigger logo than the others. Scale the sponsors' logos to the following sizes without using CSS.

  • Platinum: 500x200

  • Gold: 350x150

  • Silver: 200x80

  • Bronze: 150x60

Solution

 1# -*- coding: utf-8 -*-
 2from collections import OrderedDict
 3from plone import api
 4from plone.app.layout.viewlets.common import ViewletBase
 5from plone.memoize import ram
 6from ploneconf.site.behaviors.social import ISocial
 7from ploneconf.site.content.sponsor import LevelVocabulary
 8from random import shuffle
 9
10LEVEL_SIZE_MAPPING = {
11    'platinum': (500, 200),
12    'gold': (350, 150),
13    'silver': (200, 80),
14    'bronze': (150, 60),
15}
16
17
18class SocialViewlet(ViewletBase):
19
20    def lanyrd_link(self):
21        adapted = ISocial(self.context)
22        return adapted.lanyrd
23
24
25class SponsorsViewlet(ViewletBase):
26
27    def _sponsors_cachekey(method, self):
28        brains = api.content.find(portal_type='sponsor')
29        cachekey = sum([int(i.modified) for i in brains])
30        return cachekey
31
32    @ram.cache(_sponsors_cachekey)
33    def _sponsors(self):
34        results = []
35        for brain in api.content.find(portal_type='sponsor'):
36            obj = brain.getObject()
37            scales = api.content.get_view(
38                name='images',
39                context=obj,
40                request=self.request)
41            width, height = LEVEL_SIZE_MAPPING[obj.level]
42            scale = scales.scale(
43                'logo',
44                width=width,
45                height=height,
46                direction='down')
47            tag = scale.tag() if scale else None
48            if not tag:
49                # only display sponsors with a logo
50                continue
51            results.append({
52                'title': obj.title,
53                'description': obj.description,
54                'tag': tag,
55                'url': obj.url or obj.absolute_url(),
56                'level': obj.level
57            })
58        return results
59
60    def sponsors(self):
61        sponsors = self._sponsors()
62        if not sponsors:
63            return
64        results = OrderedDict()
65        levels = [i.value for i in LevelVocabulary]
66        for level in levels:
67            level_sponsors = []
68            for sponsor in sponsors:
69                if level == sponsor['level']:
70                    level_sponsors.append(sponsor)
71            if not level_sponsors:
72                continue
73            shuffle(level_sponsors)
74            results[level] = level_sponsors
75        return results