29. Workflow, roles and permissions – Mastering Plone Development

29. Workflow, roles and permissions#

In this part you will:

  • Allow self-registration

  • Constrain which content types can be added to the schedule folder

  • Grant local roles

  • Create a custom workflow for talks

Tools and techniques covered:

  • folder constraints

  • local roles

  • workflow

Check out mastering-plone-project at tag block:

git checkout block

The code at the end of the chapter:

git checkout user_generated_content

More info in The code for the training

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

29.1. Self-registration#

  • Go to the Security control panel at http://localhost:3000/controlpanel/security and enable self-registration.

  • Leave Enable User Folders off unless you want a community site, in which users can create any content they want in their home folder.

  • Select the option Use email address as login name.

29.2. Constrain types to be addable#

On the schedule page, select Restrictions… http://localhost:8080/Plone/schedule/folder_constraintypes_form from the Add new menu. Restrict to only allow adding talks.

Note

This action is only available in Plone's Classic UI frontend, and not its Volto frontend.

29.3. Grant local roles#

On the schedule page, go to Sharing. Check the box for Can add for the group Logged-in users, and save. Now every logged-in user can add content in this folder (and only this folder).

The Can add column grants the Contributor role to this group within this folder.

By combining the type constraints and the local roles on this folder, we have made it so that non-admin users can create and submit talks inside the schedule.

29.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 do not 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: http://localhost:8080/Plone/portal_workflow/manage

  • 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 role (that's 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 new workflow allows contributors to see and edit talks they created themselves, but not talks submitted by other contributors until they are published.

29.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 Generic Setup profile.

Export the workflow#

<object meta_type="Plone Workflow Tool" name="portal_workflow"


- Copy {file}`workflows/talks_workflow/definition.xml` into {file}`backend/src/ploneconf/site/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.)

### 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 {file}`src/ploneconf/site/profiles/default/registry/main.xml`:

```{code-block} xml
<record name="plone.enable_self_reg">
  <value>True</value>
</record>

Grant local roles and constrain types to be addable#

Since the granting of local roles applies only to a certain folder in the site, we could easily do it by hand instead of writing code for it. But for testability and repeatability (there is a conference every year!), we should create the initial content structure automatically and also apply needed local roles.

Let's add an upgrade step to do this as well as importing the workflow and new registry setting.

Update the profile version in backend/src/ploneconf/site/profiles/default/metadata.xml:

1<?xml version="1.0" encoding="utf-8"?>
2<metadata>
3  <version>1004</version>
4  <dependencies>
5    <dependency>profile-plone.volto:default</dependency>
6    <dependency>profile-plone.app.caching:default</dependency>
7    <dependency>profile-plone.app.caching:with-caching-proxy</dependency>
8  </dependencies>
9</metadata>

Register the new upgrade step in backend/src/ploneconf/site/upgrades/configure.zcml:

  <genericsetup:upgradeSteps
      profile="ploneconf.site:default"
      source="1003"
      destination="1004"
      >
    <genericsetup:upgradeDepends
        title="Add talks workflow"
        description="Run workflow and plone.app.registry import steps"
        import_steps="plone.app.registry workflow"
        />
    <genericsetup:upgradeStep
        title="Configure talk permissions"
        description="Configure local roles and type constraints for talk schedule"
        handler="ploneconf.site.upgrades.v1004.configure_talk_permissions"
        />
  </genericsetup:upgradeSteps>

Create the file backend/src/ploneconf/site/upgrades/v1004.py:

 1from plone import api
 2from Products.CMFPlone.interfaces import constrains
 3import logging
 4
 5logger = logging.getLogger(__name__)
 6
 7
 8def configure_talk_permissions(context):
 9    talks_folder = api.content.get("/schedule")
10
11    # Allow logged-in users to create content
12    api.group.grant_roles(
13        groupname='AuthenticatedUsers',
14        roles=['Contributor'],
15        obj=talks_folder)
16
17    # Constrain addable types to talk
18    behavior = constrains.ISelectableConstrainTypes(talks_folder)
19    behavior.setConstrainTypesMode(constrains.ENABLED)
20    behavior.setLocallyAllowedTypes(['talk'])
21    behavior.setImmediatelyAddableTypes(['talk'])
22    logger.info(f'Added and configured {talks_folder.absolute_url()}')

Once we apply the upgrade step, the schedule page is updated with the appropriate local roles and constraints.

29.6. Exercise#

In Upgrade steps we wrote an upgrade step to create the basic page structure of the site. But we want that to be created not only during an upgrade, but also when a new site is created by hand or in tests.

One way to do this is to create a list of dictionaries containing the type, parent and title plus optionally workflow state etc. to create an initial structure. In some projects it could also make sense to have additional profiles besides default:

  • a demo or content profile that creates the initial structure

  • a testing profile that creates dummy content (talks, speakers etc) for tests

Create an optional Generic Setup profile content that creates the content, grants local roles and sets constraints.

Solution

Register the profile in profiles.zcml

<genericsetup:registerProfile
    name="content"
    title="PloneConf Site initial content"
    directory="profiles/content"
    description="Extension profile to add initial content"
    provides="Products.GenericSetup.interfaces.EXTENSION"
    post_handler=".setuphandlers.post_handler_content"
    />

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

<metadata>
  <version>1000</version>
  <dependencies>
    <dependency>profile-ploneconf.site:default</dependency>
  </dependencies>
</metadata>

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

 1STRUCTURE = [
 2    {
 3        'type': 'Document',
 4        'title': 'Schedule',
 5        'id': 'schedule',
 6        'description': 'Talks of the conference',
 7        'state': 'published',
 8        'allowed_types': ['talk'],
 9        'local_roles': [{
10            'group': 'AuthenticatedUsers',
11            'roles': ['Contributor']
12        }],
13    },
14    {
15        'type': 'Document',
16        'title': 'Training',
17        'id': 'training',
18        'state': 'published',
19    },
20    {
21        'type': 'Document',
22        'title': 'News',
23        'id': 'news',
24        'description': 'News about the Plone Conference',
25        'state': 'published',
26        'children': [{
27            'type': 'News Item',
28            'title': 'Submit your talks!',
29            'id': 'submit-your-talks',
30            'description': 'Talk submission is open',
31            'state': 'published', }
32        ],
33    },
34    {
35        'type': 'Document',
36        'title': 'Events',
37        'id': 'events',
38        'description': 'Dates to keep in mind',
39        'state': 'published',
40    },
41    {
42        'type': 'Document',
43        'title': 'Sponsors',
44        'id': 'sponsors',
45        'state': 'published',
46    },
47    {
48        'type': 'Document',
49        'title': 'Sprint',
50        'id': 'sprint',
51        'description': 'Work together',
52        'state': 'published',
53    },
54]

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

 1from Products.CMFPlone.interfaces import constrains
 2from zope.lifecycleevent import modified
 3
 4import logging
 5
 6
 7default_profile = "profile-ploneconf.site:default"
 8logger = logging.getLogger(__name__)
 9
10
11def post_handler_content(context):
12    portal = api.portal.get()
13    for item in STRUCTURE:
14        _create_content(item, portal)
15
16
17def _create_content(item_dict, container, force=False):
18    if not force and container.get(item_dict['id'], None) is not None:
19        return
20
21    # Extract info that can't be passed to api.content.create
22    allowed_types = item_dict.pop('allowed_types', None)
23    local_roles = item_dict.pop('local_roles', [])
24    children = item_dict.pop('children', [])
25    state = item_dict.pop('state', None)
26
27    if not item_dict['id'] in portal:
28        new_content = api.content.create(
29            container=container,
30            safe_id=True,
31            **item_dict
32        )
33        logger.info(f'Created "{new_content.portal_type}" at "{new_content.absolute_url()}"')
34
35    if allowed_types is not None:
36        _constrain(new_content, allowed_types)
37    for local_role in local_roles:
38        api.group.grant_roles(
39            groupname=local_role['group'],
40            roles=local_role['roles'],
41            obj=new_content)
42    if state is not None:
43        api.content.transition(new_content, to_state=state)
44
45    modified(new_content)
46    # call recursively for children
47    for subitem in children:
48        _create_content(subitem, new_content)
49
50
51def _constrain(context, allowed_types):
52    behavior = constrains.ISelectableConstrainTypes(context)
53    behavior.setConstrainTypesMode(constrains.ENABLED)
54    behavior.setLocallyAllowedTypes(allowed_types)
55    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).