31. Workflow, Roles and Permissions – Mastering Plone 6 development

31. Workflow, Roles and Permissions#

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 part you will:

  • Allow self-registration

  • Constrain which content types can be added to the (folderish) talk page

  • Grant local roles

  • Create a custom workflow for talks

Tools and techniques covered:

  • workflow

  • local roles

Backend chapter

Checkout ploneconf.site at tag "searchable":

git checkout searchable

The code at the end of the chapter:

git checkout user_generated_content

More info in The code for the training

31.1. Self-registration#

  • Go to the control panel security 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'.

31.2. Constrain types to be addable#

31.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 achieved, that only logged-in users can create and submit talks in this folderish page.

31.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

  • 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 (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 the ones of other contributors.

31.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.

Export and import the workflow#

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

  • Drop the file workflows.xml into src/ploneconf/site/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"/>
     <bindings>
      <type type_id="talk">
       <bound-workflow workflow_id="talks_workflow"/>
      </type>
     </bindings>
    </object>
    
  • Drop workflows/talks_workflow/definition.xml in 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 src/ploneconf/site/profiles/default/registry/main.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 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 and also apply needed local roles.

We are setting up the initial content of a conference site in an upgrade step explained in upgrade step code. Let's enhance this by setting local roles and constrain types. Add the following lines to cleanup_site_structure.

 1from Products.CMFPlone.interfaces import constrains
 2
 3
 4    # Allow logged-in users to create content
 5    api.group.grant_roles(
 6        groupname='AuthenticatedUsers',
 7        roles=['Contributor'],
 8        obj=talks_folder)
 9
10    # Constrain addable types to talk
11    behavior = constrains.ISelectableConstrainTypes(talks_folder)
12    behavior.setConstrainTypesMode(constrains.ENABLED)
13    behavior.setLocallyAllowedTypes(['talk'])
14    behavior.setImmediatelyAddableTypes(['talk'])
15    logger.info(f'Added and configured {talks_folder.absolute_url()}')

Once we apply the upgrade step or reinstall our package a page talks is created with the appropriate local roles and constraints.

31.6. Exercise#

We wrote similar code to create the pages 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 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.

Create an optional GenericSetup 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).