32. User Generated Content#

How do prospective speakers submit talks? We let them register on the site and grant right to create talks. For this we go back to changing the site through-the-web.

In this chapter we:

  • allow self-registration

  • constrain which content types can be added to the talk folder

  • grant local roles

  • create a custom workflow for talks

32.1. Self-registration#

32.2. Constrain types#

  • On the talk folder select Restrictions… from the Add new menu. Only allow adding talks.

32.3. Grant local roles#

  • Go to Sharing and grant the role Can add to the group logged-in users. Now every logged-in user can add content in this folder (and only this folder).

By combining the constrain types and the local roles on this folder, we have made it so only logged-in users can create and submit talks in this folder.

32.4. A custom workflow for talks#

We still need to fix a problem: Authenticated users can see all talks, including those of other users, even if those talks are in the private state. Since we don't want this, we will create a modified workflow for talks. The new workflow will only let them see and edit talks they created themselves and not the ones of other users.

  • Go to the ZMI ‣ portal_workflow

  • See how talks have the same workflow as most content, namely (Default)

  • Go to the tab Contents, check the box next to simple_publication_workflow, click copy and paste.

  • Rename the new workflow from copy_of_simple_publication_workflow to talks_workflow.

  • Edit the workflow by clicking on it: Change the Title to Talks Workflow.

  • Click on the tab States and click on private to edit this state. In the next view select the tab Permissions.

  • Find the table column for the role Contributor and remove the permissions for Access contents information and View. Note that the Owner (i.e. the Creator) still has some permissions.

  • Do the same for the state pending

  • Go back to portal_workflow and set the new workflow talks_workflow for talks. Click Change and then Update security settings.


The add-on plone.app.workflowmanager provides a much nicer graphical user interface for this. The problem is you need a big screen to work with complex workflows.


32.5. Move the changes to the file system#

We don't want to do these steps for every new conference by hand so we move the changes into our package.

32.5.1. Import/Export the Workflow#

  • export the GenericSetup step Workflow Tool in http://localhost:8080/Plone/portal_setup/manage_exportSteps.

  • drop the file workflows.xml into profiles/default an clean out everything that is not related to talks.

    <?xml version="1.0"?>
    <object name="portal_workflow" meta_type="Plone Workflow Tool">
     <object name="talks_workflow" meta_type="Workflow"/>
      <type type_id="talk">
       <bound-workflow workflow_id="talks_workflow"/>
  • drop workflows/talks_workflow/definition.xml in profiles/default/workflows/talks_workflow/definition.xml. The other files are just definitions of the default-workflows and we only want things in our package that changes Plone.

32.5.2. Enable self-registration#

To enable self-registration you need to change the global setting that controls this option. Most global setting are stored in the registry. You can modify it by adding the following to profiles/default/registry.xml:

<record name="plone.enable_self_reg">

32.5.3. Grant local roles#

Since the granting of local roles applies only to a certain folder in the site we would not always write code for it but do it by hand. But for testability and repeatability (there is a conference every year!) we should create the initial content structure automatically.

So let's make sure some initial content is created and configured on installing the package.

To run arbitrary code during the installation of a package we use a post_handler

Our package already has such a method registered in configure.zcml. It will be automatically run when (re-)installing the add-on.

2    name="default"
3    title="ploneconf.site"
4    directory="profiles/default"
5    description="Installs the ploneconf.site add-on."
6    provides="Products.GenericSetup.interfaces.EXTENSION"
7    post_handler=".setuphandlers.post_install"
8    />

This makes sure the method post_install() in setuphandlers.py is executed after the installation. The method already exists doing nothing. You need to extend it to do what we want.

 1# -*- coding: utf-8 -*-
 2from plone import api
 3from Products.CMFPlone.interfaces import constrains
 4from Products.CMFPlone.interfaces import INonInstallable
 5from zope.interface import implementer
 7import logging
 9logger = logging.getLogger(__name__)
10PROFILE_ID = 'profile-ploneconf.site:default'
14class HiddenProfiles(object):
16    def getNonInstallableProfiles(self):
17        """Hide uninstall profile from site-creation and quickinstaller"""
18        return [
19            'ploneconf.site:uninstall',
20        ]
23def post_install(context):
24    """Post install script"""
25    # Do something at the end of the installation of this package.
26    portal = api.portal.get()
27    set_up_content(portal)
30def set_up_content(portal):
31    """Create and configure some initial content.
32    Part of this code is taken from upgrades.py
33    """
34    # Create a folder 'The event' if needed
35    if 'the-event' not in portal:
36        event_folder = api.content.create(
37            container=portal,
38            type='Folder',
39            id='the-event',
40            title=u'The event')
41    else:
42        event_folder = portal['the-event']
44    # Create folder 'Talks' inside 'The event' if needed
45    if 'talks' not in event_folder:
46        talks_folder = api.content.create(
47            container=event_folder,
48            type='Folder',
49            id='talks',
50            title=u'Talks')
51    else:
52        talks_folder = event_folder['talks']
54    # Allow logged-in users to create content
55    api.group.grant_roles(
56        groupname='AuthenticatedUsers',
57        roles=['Contributor'],
58        obj=talks_folder)
60    # Constrain addable types to talk
61    behavior = constrains.ISelectableConstrainTypes(talks_folder)
62    behavior.setConstrainTypesMode(constrains.ENABLED)
63    behavior.setLocallyAllowedTypes(['talk'])
64    behavior.setImmediatelyAddableTypes(['talk'])
65    logger.info('Added and configured {0}'.format(talks_folder.absolute_url()))
68def uninstall(context):
69    """Uninstall script"""
70    # Do something at the end of the uninstallation of this package.

Once we reinstall our package a folder talks is created with the appropriate local roles and constraints.

We wrote similar code to create the folder The Event in Upgrade steps. We need it to make sure a sane structure gets created when we create a new site by hand or in tests.

You would usually create a list of dictionaries containing the type, parent and title plus optionally layout, workflow state etc. to create an initial structure. In some projects it could also make sense to have a separate profile besides default which might be called demo or content that creates an initial structure and maybe another testing that creates dummy content (talks, speakers etc) for tests. Exercise 1#

Create a profile content that runs its own post_handler in setuphandlers.py.


Register the profile and the upgrade step in configure.zcml

    title="PloneConf Site initial content"
    description="Extension profile for PloneConf Talk to add initial content"

Also add a profiles/content/metadata.xml so the default profile gets automatically installed when installing the content profile.


Add the structure you wish to create as a list of dictionaries in setuphandlers.py:

 2    {
 3        'type': 'Folder',
 4        'title': u'The Event',
 5        'id': 'the-event',
 6        'description': u'Plone Conference 2020',
 7        'default_page': 'frontpage-for-the-event',
 8        'state': 'published',
 9        'children': [{
10            'type': 'Document',
11            'title': u'Frontpage for the-event',
12            'id': 'frontpage-for-the-event',
13            'state': 'published',
14            },
15            {
16            'type': 'Folder',
17            'title': u'Talks',
18            'id': 'talks',
19            'layout': 'talklistview',
20            'state': 'published',
21            },
22            {
23            'type': 'Folder',
24            'title': u'Training',
25            'id': 'training',
26            'state': 'published',
27            },
28            {
29            'type': 'Folder',
30            'title': u'Sprint',
31            'id': 'sprint',
32            'state': 'published',
33            },
34        ]
35    },
36    {
37        'type': 'Folder',
38        'title': u'Talks',
39        'id': 'talks',
40        'description': u'Submit your talks here!',
41        'state': 'published',
42        'layout': '@@talklistview',
43        'allowed_types': ['talk'],
44        'local_roles': [{
45            'group': 'AuthenticatedUsers',
46            'roles': ['Contributor']
47        }],
48    },
49    {
50        'type': 'Folder',
51        'title': u'News',
52        'id': 'news',
53        'description': u'News about the Plone Conference',
54        'state': 'published',
55        'children': [{
56            'type': 'News Item',
57            'title': u'Submit your talks!',
58            'id': 'submit-your-talks',
59            'description': u'Task submission is open',
60            'state': 'published', }
61        ],
62    },
63    {
64        'type': 'Folder',
65        'title': u'Events',
66        'id': 'events',
67        'description': u'Dates to keep in mind',
68        'state': 'published',
69    },

Add the method post_content() to setuphandlers.py. We pointed to that when registering the import step. And add some fancy logic to create the content from STRUCTURE.

 1from zope.lifecycleevent import modified
 4def post_content(context):
 5    portal = api.portal.get()
 6    for item in STRUCTURE:
 7        _create_content(item, portal)
10def _create_content(item_dict, container, force=False):
11    if not force and container.get(item_dict['id'], None) is not None:
12        return
14    # Extract info that can't be passed to api.content.create
15    layout = item_dict.pop('layout', None)
16    default_page = item_dict.pop('default_page', None)
17    allowed_types = item_dict.pop('allowed_types', None)
18    local_roles = item_dict.pop('local_roles', [])
19    children = item_dict.pop('children', [])
20    state = item_dict.pop('state', None)
22    new = api.content.create(
23        container=container,
24        safe_id=True,
25        **item_dict
26    )
27    logger.info('Created {0} at {1}'.format(new.portal_type, new.absolute_url()))
29    if layout is not None:
30        new.setLayout(layout)
31    if default_page is not None:
32        new.setDefaultPage(default_page)
33    if allowed_types is not None:
34        _constrain(new, allowed_types)
35    for local_role in local_roles:
36        api.group.grant_roles(
37            groupname=local_role['group'],
38            roles=local_role['roles'],
39            obj=new)
40    if state is not None:
41        api.content.transition(new, to_state=state)
43    modified(new)
44    # call recursively for children
45    for subitem in children:
46        _create_content(subitem, new)
49def _constrain(context, allowed_types):
50    behavior = constrains.ISelectableConstrainTypes(context)
51    behavior.setConstrainTypesMode(constrains.ENABLED)
52    behavior.setLocallyAllowedTypes(allowed_types)
53    behavior.setImmediatelyAddableTypes(allowed_types)

A huge benefit of this implementation is that you can add any object-attribute as a new item to item_dict. plone.api.content.create() will then set these on the new objects. This way you can also populate fields like text (using plone.app.textfield.RichTextValue) or image (using plone.namedfile.file.NamedBlobImage).