31. Vocabularies, Registry-Settings and Control Panels#

In this part you will:

  • Store custom settings in the registry

  • Create a control panel to manage custom settings

  • Create options in fields as vocabularies

  • Training story: Assign talks to rooms

Topics covered:

  • plone.app.registry

  • Vocabularies

  • Control panels

If you stop by here for more tips, not attending this training: Make sure you upgrade to Volto 14 or later.

31.1. Introduction#

Do you remember the fields audience and type_of_talk in the talk content type? We provided several options to choose from that were hard-coded in the schema.

Next we want to add a field to assign talks to a room. Since the conference next year will have different room names, these values need to be editable.

And while we're at it: It would be much better to have the options for audience and type_of_talk editable by admins as well, e.g. to be able to add Lightning Talks!

By combining the registry, a control panel and vocabularies you can allow rooms to be editable options.

To be able to to so you first need to get to know the registry.

31.2. The Registry#

The registry is used to get and set values stored in records. Each record consists of the actual value, as well as a field that describes the record in more detail. It has a nice dict-like API.

Since Plone 5 all global settings are stored in the registry.

The registry itself is provided by plone.registry and the UI to interact with it by plone.app.registry

Almost all settings in /plone_control_panel are actually stored in the registry and can be modified using its UI directly.

Open http://localhost:8080/Plone/portal_registry and filter for displayed_types. You see that you can modify the content types that should be shown in the navigation and site map. The values are the same as in http://localhost:8080/Plone/@@navigation-controlpanel, but the latter form is customized for usability.

Note

This UI for the registry is not yet available in the frontend.

31.3. Registry Records#

In Creating a dynamic frontpage with Volto blocks you already added a criterion usable for Collections in profiles/default/registry/querystring.xml. This setting is stored in the registry.

Let's look at existing values in the registry.

Go to http://localhost:3000/controlpanel/navigation and add talk to the field Displayed content types. Talks in the root will now show up in the navigation. This setting is stored in the registry record plone.displayed_types.

31.4. Accessing and modifying records in the registry#

In Python you can access the registry record with the key plone.displayed_types via plone.api. It holds convenience methods to get and set a record:

from plone import api

api.portal.get_registry_record('plone.displayed_types')
api.portal.set_registry_record('plone.smtp_host', 'my.mail.server')

The access of the registry by zope.component.getUtility is often seen in code from before the time of plone.api.

from plone.registry.interfaces import IRegistry
from zope.component import getUtility

registry = getUtility(IRegistry)
displayed_types = registry.get('plone.displayed_types')

The value of the record displayed_types is the tuple ('Image', 'File', 'Link', 'News Item', 'Folder', 'Document', 'Event', 'talk')

31.5. Custom registry records#

Now let's add our own custom settings:

  • Is talk submission open or closed?

  • Which rooms are available for talks?

While we're at it we can also add new settings types_of_talk and audiences that we will use later for the fields type_of_talk and audience.

To define custom records, you write the same type of schema as you already did for content types or for behaviors:

Add a file browser/controlpanel.py:

  1from plone.autoform import directives
  2from plone import schema
  3from zope.interface import Interface
  4
  5import json
  6
  7VOCABULARY_SCHEMA = json.dumps(
  8    {
  9        "type": "object",
 10        "properties": {
 11            "items": {
 12                "type": "array",
 13                "items": {
 14                    "type": "object",
 15                    "properties": {
 16                        "token": {"type": "string"},
 17                        "titles": {
 18                            "type": "object",
 19                            "properties": {
 20                                "lang": {"type": "string"},
 21                                "title": {"type": "string"},
 22                            },
 23                        },
 24                    },
 25                },
 26            }
 27        },
 28    }
 29)
 30
 31
 32class IPloneconfSettings(Interface):
 33
 34    talk_submission_open = schema.Bool(
 35        title="Allow talk submission",
 36        description="Allow the submission of talks for anonymous user",
 37        default=False,
 38        required=False,
 39    )
 40
 41    types_of_talk = schema.JSONField(
 42        title="Types of Talk",
 43        description="Available types of a talk",
 44        required=False,
 45        schema=VOCABULARY_SCHEMA,
 46        default={
 47            "items": [
 48                {
 49                    "token": "talk",
 50                    "titles": {
 51                        "en": "Talk",
 52                        "de": "Vortrag",
 53                    },
 54                },
 55                {
 56                    "token": "lightning-talk",
 57                    "titles": {
 58                        "en": "Lightning-Talk",
 59                        "de": "Lightning-Talk",
 60                    },
 61                },
 62            ]
 63        },
 64        missing_value={"items": []},
 65    )
 66    directives.widget(
 67        "types_of_talk",
 68        frontendOptions={
 69            "widget": "vocabularyterms",
 70        },
 71    )
 72
 73    audiences = schema.JSONField(
 74        title="Audience",
 75        description="Available audiences of a talk",
 76        required=False,
 77        schema=VOCABULARY_SCHEMA,
 78        default={
 79            "items": [
 80                {
 81                    "token": "beginner",
 82                    "titles": {
 83                        "en": "Beginner",
 84                        "de": "Anfänger",
 85                    },
 86                },
 87                {
 88                    "token": "advanced",
 89                    "titles": {
 90                        "en": "Advanced",
 91                        "de": "Fortgeschrittene",
 92                    },
 93                },
 94                {
 95                    "token": "professional",
 96                    "titles": {
 97                        "en": "Professional",
 98                        "de": "Profi",
 99                    },
100                },
101            ]
102        },
103        missing_value={"items": []},
104    )
105    directives.widget(
106        "audiences",
107        frontendOptions={
108            "widget": "vocabularyterms",
109        },
110    )
111
112    rooms = schema.JSONField(
113        title="Rooms",
114        description="Available rooms of the conference",
115        required=False,
116        schema=VOCABULARY_SCHEMA,
117        default={
118            "items": [
119                {
120                    "token": "101",
121                    "titles": {
122                        "en": "101",
123                        "de": "101",
124                    },
125                },
126                {
127                    "token": "201",
128                    "titles": {
129                        "en": "201",
130                        "de": "201",
131                    },
132                },
133                {
134                    "token": "auditorium",
135                    "titles": {
136                        "en": "Auditorium",
137                        "de": "Auditorium",
138                    },
139                },
140            ]
141        },
142        missing_value={"items": []},
143    )
144    directives.widget(
145        "rooms",
146        frontendOptions={
147            "widget": "vocabularyterms",
148        },
149    )

The motivation to use schema.JSONField instead of schema.List is described as follows.

The options for the types of a talk, the room and the audience may change. A modification of the feeding vocabulary would mean that already used options are no longer available, which would corrupt the data of the concerned talks. We can "future-proof" this vocabulary with JSONFields that store a vocabulary source in the registry. This vocabulary is a list of dictionaries, with keys that never change, and values that may be modified when necessary. See the default values to understand what is stored in the registry: Example types_of_talk:

[
    {
        "token": "talk",
        "titles": {
            "en": "Talk",
            "de": "Vortrag",
        },
    },
    {
        "token": "lightning-talk",
        "titles": {
            "en": "Lightning-Talk",
            "de": "Lightning-Talk",
        },
    },
]

We now register this schema IPloneconfSettings for the registry. Add the following to profiles/default/registry/main.xml. With this statement the registry is extended by one record per IPloneconfSettings schema field.

<?xml version="1.0"?>
<registry
    xmlns:i18n="http://xml.zope.org/namespaces/i18n"
    i18n:domain="ploneconf.site">

  <records
      interface="ploneconf.site.browser.controlpanel.IPloneconfSettings"
      prefix="ploneconf" />

</registry>

Note

The prefix allows you to access these records with a shortcut: You can use ploneconf.rooms instead of ploneconf.site.browser.controlpanel.IPloneconfSettings.rooms.

After reinstalling the package to apply the registry changes, you can access and modify these registry records as described above. Either use http://localhost:8080/Plone/portal_registry or Python:

from plone import api

api.portal.get_registry_record('ploneconf.rooms')

Note

In training code ploneconf.site, we use Python to define the registry records. Alternatively you could add these registry entries with Generic Setup.

The following creates a new entry ploneconf.talk_submission_open with Generic Setup:

1<record name="ploneconf.talk_submission_open">
2  <field type="plone.registry.field.Bool">
3    <title>Allow talk submission</title>
4    <description>Allow the submission of talks for anonymous users</description>
5    <required>False</required>
6  </field>
7  <value>False</value>
8</record>

When creating a new vanilla Plone instance, a lot of default settings are created that way. See https://github.com/plone/Products.CMFPlone/blob/master/Products/CMFPlone/profiles/dependencies/registry.xml to see how Products.CMFPlone registers values.

Add a custom control panel

Now you will add a custom control panel to edit all settings related to our package with a nice UI.

To register a control panel for the frontend and Plone Classic you need quite a bit of boiler-plate:

  1from plone import schema
  2from plone.app.registry.browser.controlpanel import ControlPanelFormWrapper
  3from plone.app.registry.browser.controlpanel import RegistryEditForm
  4from plone.autoform import directives
  5from plone.restapi.controlpanels import RegistryConfigletPanel
  6from zope.component import adapter
  7from zope.interface import Interface
  8
  9import json
 10
 11VOCABULARY_SCHEMA = json.dumps(
 12    {
 13        "type": "object",
 14        "properties": {
 15            "items": {
 16                "type": "array",
 17                "items": {
 18                    "type": "object",
 19                    "properties": {
 20                        "token": {"type": "string"},
 21                        "titles": {
 22                            "type": "object",
 23                            "properties": {
 24                                "lang": {"type": "string"},
 25                                "title": {"type": "string"},
 26                            },
 27                        },
 28                    },
 29                },
 30            }
 31        },
 32    }
 33)
 34
 35
 36class IPloneconfSettings(Interface):
 37
 38    talk_submission_open = schema.Bool(
 39        title="Allow talk submission",
 40        description="Allow the submission of talks for anonymous user",
 41        default=False,
 42        required=False,
 43    )
 44
 45    types_of_talk = schema.JSONField(
 46        title="Types of Talk",
 47        description="Available types of a talk",
 48        required=False,
 49        schema=VOCABULARY_SCHEMA,
 50        default={
 51            "items": [
 52                {
 53                    "token": "talk",
 54                    "titles": {
 55                        "en": "Talk",
 56                        "de": "Vortrag",
 57                    },
 58                },
 59                {
 60                    "token": "lightning-talk",
 61                    "titles": {
 62                        "en": "Lightning-Talk",
 63                        "de": "Lightning-Talk",
 64                    },
 65                },
 66            ]
 67        },
 68        missing_value={"items": []},
 69    )
 70    directives.widget(
 71        "types_of_talk",
 72        frontendOptions={
 73            "widget": "vocabularyterms",
 74        },
 75    )
 76
 77    audiences = schema.JSONField(
 78        title="Audience",
 79        description="Available audiences of a talk",
 80        required=False,
 81        schema=VOCABULARY_SCHEMA,
 82        default={
 83            "items": [
 84                {
 85                    "token": "beginner",
 86                    "titles": {
 87                        "en": "Beginner",
 88                        "de": "Anfänger",
 89                    },
 90                },
 91                {
 92                    "token": "advanced",
 93                    "titles": {
 94                        "en": "Advanced",
 95                        "de": "Fortgeschrittene",
 96                    },
 97                },
 98                {
 99                    "token": "professional",
100                    "titles": {
101                        "en": "Professional",
102                        "de": "Profi",
103                    },
104                },
105            ]
106        },
107        missing_value={"items": []},
108    )
109    directives.widget(
110        "audiences",
111        frontendOptions={
112            "widget": "vocabularyterms",
113        },
114    )
115
116    rooms = schema.JSONField(
117        title="Rooms",
118        description="Available rooms of the conference",
119        required=False,
120        schema=VOCABULARY_SCHEMA,
121        default={
122            "items": [
123                {
124                    "token": "101",
125                    "titles": {
126                        "en": "101",
127                        "de": "101",
128                    },
129                },
130                {
131                    "token": "201",
132                    "titles": {
133                        "en": "201",
134                        "de": "201",
135                    },
136                },
137                {
138                    "token": "auditorium",
139                    "titles": {
140                        "en": "Auditorium",
141                        "de": "Auditorium",
142                    },
143                },
144            ]
145        },
146        missing_value={"items": []},
147    )
148    directives.widget(
149        "rooms",
150        frontendOptions={
151            "widget": "vocabularyterms",
152        },
153    )
154
155
156class PloneconfRegistryEditForm(RegistryEditForm):
157    schema = IPloneconfSettings
158    schema_prefix = "ploneconf"
159    label = "Ploneconf Settings"
160
161
162
163class PloneConfControlPanelFormWrapper(ControlPanelFormWrapper):
164    form = PloneconfRegistryEditForm
165
166
167@adapter(Interface, Interface)
168class PloneConfRegistryConfigletPanel(RegistryConfigletPanel):
169    """Volto control panel"""
170
171    schema = IPloneconfSettings
172    schema_prefix = "ploneconf"
173    configlet_id = "ploneconf-controlpanel"
174    configlet_category_id = "Products"
175    title = "Ploneconf Settings"
176    group = "Products"

You also need to register the view and the adapter in browser/configure.zcml:

 1    <browser:page
 2        name="ploneconf-controlpanel"
 3        for="Products.CMFPlone.interfaces.IPloneSiteRoot"
 4        class=".controlpanel.PloneConfControlPanelFormWrapper"
 5        permission="cmf.ManagePortal"
 6        layer="ploneconf.site.interfaces.IPloneconfSiteLayer"
 7        />
 8
 9    <adapter
10        factory="ploneconf.site.browser.controlpanel.PloneConfRegistryConfigletPanel"
11        name="ploneconf-controlpanel" />

Finally register the configlet with Generic Setup so that it gets listed in the Site Setups panels list. Add a file profiles/default/controlpanel.xml:

 1<?xml version="1.0"?>
 2<object name="portal_controlpanel">
 3  <configlet
 4      title="Ploneconf Settings"
 5      action_id="ploneconf-controlpanel"
 6      appId="ploneconf-controlpanel"
 7      category="Products"
 8      condition_expr=""
 9      icon_expr=""
10      url_expr="string:${portal_url}/@@ploneconf-controlpanel"
11      visible="True">
12    <permission>Manage portal</permission>
13  </configlet>
14</object>

After applying the profile (for example, by reinstalling the package), your control panel shows up.

../_images/volto_ploneconf_controlpanel_overview.png

The address is http://localhost:3000/controlpanel/ploneconf-controlpanel

../_images/volto_ploneconf_controlpanel.png

As you can see in the control panel configlet for the ploneconf.site package, the entries can be modified and reordered. Changes are reflected in the registry as the configlet is registered as a wrapped edit form of the IPloneconfSettings schema.

Note

A short remark on the frontend widget. We want the VocabularyTermsWidget to be applied. Thus we specify a hint, using a so-called "tagged value", the name of the frontend widget to be applied for the three control panel fields in our backend schema. Thus no widget registration in the frontend app is needed.

directives.widget(
    'types_of_talk',
    frontendOptions={
        "widget": 'vocabularyterms',
    })

31.6. Vocabularies#

Now the custom settings are stored in the registry and we can modify them in a nice way as site administrators. We still need to use these options in talks.

To do so we turn them into vocabularies.

Vocabularies are often used for selection fields. They have many benefits:

  • They allow you to separate the select option values from the content type schema. This means that they can be edited via the UI.

  • A vocabulary can even be set dynamically. The available options can change depending on existing content, the role of the user, or even the time of day.

Create a file vocabularies.py and write code that generates vocabularies from these settings:

 1from plone import api
 2from zope.interface import provider
 3from zope.schema.interfaces import IVocabularyFactory
 4from zope.schema.vocabulary import SimpleVocabulary
 5
 6
 7@provider(IVocabularyFactory)
 8def TalkTypesVocabulary(context):
 9    name = "ploneconf.types_of_talk"
10    registry_record_value = api.portal.get_registry_record(name)
11    items = registry_record_value.get("items", [])
12    lang = api.portal.get_current_language()
13    return SimpleVocabulary.fromItems(
14        [[item["token"], item["token"], item["titles"][lang]] for item in items]
15    )
16
17
18@provider(IVocabularyFactory)
19def AudiencesVocabulary(context):
20    name = "ploneconf.audiences"
21    registry_record_value = api.portal.get_registry_record(name)
22    items = registry_record_value.get("items", [])
23    lang = api.portal.get_current_language()
24    return SimpleVocabulary.fromItems(
25        [[item["token"], item["token"], item["titles"][lang]] for item in items]
26    )
27
28
29@provider(IVocabularyFactory)
30def RoomsVocabularyFactory(context):
31    name = "ploneconf.rooms"
32    registry_record_value = api.portal.get_registry_record(name)
33    items = registry_record_value.get("items", [])
34    lang = api.portal.get_current_language()
35    return SimpleVocabulary.fromItems(
36        [[item["token"], item["token"], item["titles"][lang]] for item in items]
37    )

You can now register these vocabularies as named utilities in configure.zcml:

<utility
    name="ploneconf.types_of_talk"
    component="ploneconf.site.vocabularies.TalkTypesVocabulary" />
<utility
    name="ploneconf.audiences"
    component="ploneconf.site.vocabularies.AudiencesVocabulary" />
<utility
    name="ploneconf.rooms"
    component="ploneconf.site.vocabularies.RoomsVocabularyFactory" />

From now on you can use these vocabulary by referring to their name, e.g. ploneconf.rooms.

Note

  • Plone comes with many useful named vocabularies that you can use in your own projects, for example plone.app.vocabularies.Users or plone.app.vocabularies.PortalTypes.

  • See https://github.com/plone/plone.app.vocabularies/ for a list of vocabularies.

  • We turn the values from the registry into a dynamic SimpleVocabulary that can be used in the schema.

  • You could use the context with which the vocabulary is called or the request (using getRequest from zope.globalrequest) to constrain the values in the vocabulary.

See also

Plone documentation Vocabularies.

31.7. Using vocabularies in a schema#

To use a vocabulary in a schema field, replace the attribute values with vocabulary, and point to a vocabulary by its name:

1type_of_talk = schema.Choice(
2    title='Type of talk',
3    vocabulary='ploneconf.types_of_talk',
4    required=True,
5)

Don't forget to add the new field room.

Edit content/talk.py:

 1from plone.app.textfield import RichText
 2from plone.autoform import directives
 3from plone.dexterity.content import Container
 4from plone.namedfile.field import NamedBlobImage
 5from plone.schema.email import Email
 6from plone.supermodel import model
 7from z3c.form.browser.checkbox import CheckBoxFieldWidget
 8from z3c.form.browser.radio import RadioFieldWidget
 9from plone import schema
10from zope.interface import implementer
11
12
13class ITalk(model.Schema):
14    """Define a content type schema for Talks"""
15
16    directives.widget(type_of_talk=RadioFieldWidget)
17    type_of_talk = schema.Choice(
18        title='Type of talk',
19        vocabulary='ploneconf.types_of_talk',
20        required=True,
21    )
22
23    details = RichText(
24        title='Details',
25        description='Description of the talk (max. 2000 characters)',
26        max_length=2000,
27        required=True,
28    )
29
30    directives.widget(audience=CheckBoxFieldWidget)
31    audience = schema.Set(
32        title='Audience',
33        value_type=schema.Choice(vocabulary='ploneconf.audiences'),
34        required=False,
35    )
36
37    speaker = schema.TextLine(
38        title='Speaker',
39        description='Name (or names) of the speaker',
40        required=False,
41    )
42
43    company = schema.TextLine(
44        title='Company',
45        required=False,
46    )
47
48    email = Email(
49        title='Email',
50        description='Email adress of the speaker',
51        required=False,
52    )
53
54    website = schema.TextLine(
55        title='Website',
56        required=False,
57    )
58
59    twitter = schema.TextLine(
60        title='Twitter name',
61        required=False,
62    )
63
64    github = schema.TextLine(
65        title='Github username',
66        required=False,
67    )
68
69    image = NamedBlobImage(
70        title='Image',
71        description='Portrait of the speaker',
72        required=False,
73    )
74
75    speaker_biography = RichText(
76        title='Speaker Biography (max. 1000 characters)',
77        max_length=1000,
78        required=False,
79    )
80
81    room = schema.Choice(
82        title='Room',
83        vocabulary='ploneconf.rooms',
84        required=False,
85    )
86
87
88@implementer(ITalk)
89class Talk(Container):
90    """Talk instance class"""

One tiny thing is still missing: We should display the room.

Modify frontend/src/components/Views/Talk.jsx an add this after the When component:

1    {content.room && (
2      <>
3        <Header dividing sub>
4          Where
5        </Header>
6        <p>{content.room.title}</p>
7      </>
8    )}

The complete TalkView

import React from 'react';
import { flattenToAppURL } from '@plone/volto/helpers';
import {
  Container,
  Header,
  Image,
  Icon,
  Label,
  Segment,
} from 'semantic-ui-react';
import { Helmet } from '@plone/volto/helpers';
import { When } from '@plone/volto/components/theme/View/EventDatesInfo';

const TalkView = (props) => {
  const { content } = props;
  const color_mapping = {
    Beginner: 'green',
    Advanced: 'yellow',
    Professional: 'red',
  };

  return (
    <Container id="page-talk">
      <Helmet title={content.title} />
      <h1 className="documentFirstHeading">
        {content.type_of_talk.title}: {content.title}
      </h1>
      <Segment floated="right">
        {content.start && !content.hide_date && (
          <>
            <Header dividing sub>
              When
            </Header>
            <When
              start={content.start}
              end={content.end}
              whole_day={content.whole_day}
              open_end={content.open_end}
            />
          </>
        )}
        {content.room && (
          <>
            <Header dividing sub>
              Where
            </Header>
            <p>{content.room.title}</p>
          </>
        )}
        {content.audience && (
          <Header dividing sub>
            Audience
          </Header>
        )}
        {content.audience.map((item) => {
          let audience = item.title;
          let color = color_mapping[audience] || 'green';
          return (
            <Label key={audience} color={color}>
              {audience}
            </Label>
          );
        })}
      </Segment>
      {content.description && (
        <p className="documentDescription">{content.description}</p>
      )}
      {content.details && (
        <div dangerouslySetInnerHTML={{ __html: content.details.data }} />
      )}
      {content.speaker && (
        <Segment clearing>
          <Header dividing>{content.speaker}</Header>
          {content.website ? (
            <p>
              <a href={content.website}>{content.company}</a>
            </p>
          ) : (
            <p>{content.company}</p>
          )}
          {content.email && (
            <p>
              Email: <a href={`mailto:${content.email}`}>{content.email}</a>
            </p>
          )}
          {content.twitter && (
            <p>
              Twitter:{' '}
              <a href={`https://twitter.com/${content.twitter}`}>
                {content.twitter.startsWith('@')
                  ? content.twitter
                  : '@' + content.twitter}
              </a>
            </p>
          )}
          {content.github && (
            <p>
              Github:{' '}
              <a href={`https://github.com/${content.github}`}>
                {content.github}
              </a>
            </p>
          )}
          {content.image && (
            <Image
              src={flattenToAppURL(content.image.scales.preview.download)}
              size="small"
              floated="right"
              alt={content.image_caption}
              avatar
            />
          )}
          {content.speaker_biography && (
            <div
              dangerouslySetInnerHTML={{
                __html: content.speaker_biography.data,
              }}
            />
          )}
        </Segment>
      )}
    </Container>
  );
};
export default TalkView;

By the way: When using a vocabulary you can also drop the annoying item.title || item.token pattern.

31.8. Summary#

  • You successfully combined the registry, a control panel and vocabularies to allow managing field options by admins.

  • It seems like a lot but you will certainly use dynamic vocabularies, control panels and the registry in all of your future Plone projects in one way or another.