31.1. Complex behaviors [voting story]#
In this part you will:
Write a behavior that enables voting on content
Use annotations to store the votes on an object
Topics covered:
Behaviors with a factory class
Marker interface for a behavior
Using annotations as storage layer
Check out mastering-plone-votable-add-on at tag initial:
git checkout initial
The code at the end of the chapter:
git checkout behaviors
More info in The code for the training
Members of the conference program committee will vote on talks to be accepted for the conference.
Schema design#
We will create a behavior to store the votes on a talk.
Basically, this field will add a field votes to store a cumulative count of different vote categories.
We will not add the field votes to the schema, because this field should not be edited directly.
Instead we are going to store the information about votes in an annotation.
The behavior will use an adapter that controls how data is stored and retrieved.
Add the behavior#
In your editor, open the mastering-plone-votable-add-on add-on that you created in the previous chapter.
Tip
Later in your daily work you can use plonecli to generate a behavior. In this training we go step by step through the code to understand a behavior and its capabilities.
To start, we create a directory backend/src/ploneconf/votable/behaviors with an empty __init__.py file.
To let Plone know about the behavior we are writing, we include the behaviors module in backend/src/ploneconf/votable/configure.zcml:
1<configure xmlns="...">
2
3 ...
4 <include package=".behaviors" />
5 ...
6
7</configure>
Next, create a backend/src/ploneconf/votable/behaviors/configure.zcml where we register our to-be-written behavior.
1<configure
2 xmlns="http://namespaces.zope.org/zope"
3 xmlns:browser="http://namespaces.zope.org/browser"
4 xmlns:plone="http://namespaces.plone.org/plone"
5 xmlns:zcml="http://namespaces.zope.org/zcml"
6 i18n_domain="plone"
7 >
8
9 <include
10 package="plone.behavior"
11 file="meta.zcml"
12 />
13
14 <plone:behavior
15 name="ploneconf.votable.votable"
16 title="Votable"
17 description="Support liking and disliking of content"
18 factory=".votable.Votable"
19 provides=".votable.IVotable"
20 marker=".votable.IVotableMarker"
21 />
22
23</configure>
There are important differences compared to the first simple behavior in Behaviors:
There is a
markerinterface.There is a
factory.
The first simple behavior discussed in Behaviors was registered only with the provides attribute:
<plone:behavior
title="Featured"
name="ploneconf.featured"
description="Control if a item is shown on the front page"
provides=".featured.IFeatured"
/>
The factory is a class that provides the behavior logic and controls how the attributes from the behavior schema are accessed.
A factory in Plone/Zope is an adapter, which means a function or class that adapts an object to provide an interface.
We can use the following short form to access the features of a behavior of an object: votable = IVotable(object).
The expression IVotable(object) is short for "Get the appropriate adapter for interface IVotable that is compatible with my object!".
The result is an adapted object with the behavior features.
You can for example get the value of votes with IVotable(object).votes.
But you can not get the votes with object.votes, as the object itself does not know about votes.
Only the adapted object IVotable(object) supports voting.
Since the provides interface is now provided by the adapter rather than the object itself,
the marker is introduced as a marker interface on the object.
This lets us register additional adapters only for objects that have the behavior.
We now implement what we registered.
Therefore we create a file backend/src/ploneconf/votable/behaviors/votable.py with the schema, marker interface, and the factory.
1from plone.autoform.interfaces import IFormFieldProvider
2from plone.supermodel import model
3from zope import schema
4from zope.interface import Interface
5from zope.interface import provider
6
7
8class IVotableMarker(Interface):
9 """Marker interface for content types or instances that should be votable"""
10 pass
11
12
13@provider(IFormFieldProvider)
14class IVotable(model.Schema):
15 """Schema for the votable behavior
16
17 IVotable(object) returns the adapted object with votable behavior
18 """
19
20 voting_enabled = schema.Bool(
21 title="Voting enabled?",
22 readonly=True,
23 )
24
25 def vote(vote):
26 """
27 Store the vote information and store the user(name)
28 to ensure that the user does not vote twice.
29 """
30
31 def average_vote():
32 """
33 Return the average voting for an item.
34 """
35
36 def has_votes():
37 """
38 Return whether anybody ever voted for this item.
39 """
40
41 def already_voted():
42 """
43 Return the information wether a person already voted.
44 """
45
46 def clear():
47 """
48 Clear the votes. Should only be called by admins.
49 """
This is a lot of code.
The IVotableMarker interface is the marker interface.
It will be used to register REST API endpoints for objects that adapts this behavior.
The IVotable interface is the schema with fields and methods.
The @provider decorator of the class ensures that the schema fields are known to other packages.
Whenever some code wants all schemas of an object, it receives the schema defined directly on the object and the additional schemata.
Additional schemata are compiled by looking for behaviors and whether they provide the IFormFieldProvider functionality.
Only then the fields are used as form fields.
We add one actual field: voting_enabled.
It is read only, so it will not appear in the edit form.
This field will be used to detect content items that have the votable behavior enabled, so that we know when to display the frontend component for voting.
Then we define the API that we are going to use in the frontend.
Now the only thing that is missing is the behavior implementation (the factory), which we add to backend/src/ploneconf/votable/behaviors/votable.py.
The factory is an adapter that adapts a content item to the behavior interface IVotable.
1from persistent.list import PersistentList
2from persistent.mapping import PersistentMapping
3from plone import api
4from plone.autoform.interfaces import IFormFieldProvider
5from plone.supermodel import model
6from zope import schema
7from zope.annotation.interfaces import IAnnotations
8from zope.component import adapter
9from zope.interface import implementer
10from zope.interface import Interface
11from zope.interface import provider
12
13# ...
14
15KEY = "ploneconf.votable.behaviors.votable.Votable"
16
17@implementer(IVotable)
18@adapter(IVotableMarker)
19class Votable:
20 """Adapter implementing the votable behavior"""
21
22 def __init__(self, context):
23 self.context = context
24 annotations = IAnnotations(context)
25 if KEY not in annotations:
26 # You know what happens if we don't use persistent classes here?
27 annotations[KEY] = PersistentMapping({
28 "voted": PersistentList(),
29 "votes": PersistentMapping(),
30 })
31 self.annotations = annotations[KEY]
32
33 @property
34 def voting_enabled(self):
35 return True
36
37 # getter
38 @property
39 def votes(self):
40 return self.annotations["votes"]
41
42 # setter
43 # def votes(self, value):
44 # """We do not define a setter.
45 # Function 'vote' is the only one that shall set attributes
46 # of the context object."""
47 # self.annotations["votes"] = value
48
49 # getter
50 @property
51 def voted(self):
52 return self.annotations["voted"]
53
54 # setter
55 # def voted(self, value):
56 # self.annotations["voted"] = value
In our __init__ method we get annotations from the object.
We look for data with a key unique for this behavior.
If the annotation with this key does not exist, because no one has voted on this object yet, we create it.
We work with PersistentMapping and PersistentList.
A PersistentMapping is simply an implementation of the Python dict type (via the standard library UserDict base class) which ensures that changes are detected to store in the ZODB.
Next we provide the internal fields via properties. Using this form of property makes them read-only properties, as we do not define setters/mutators.
The voting_enabled property returns True to indicate that the voting behavior is active.
The votes property returns the current vote totals.
The voted property returns a list of users who have voted.
Let's continue with the implementation of the methods for the behavior adapter:
1 def vote(self, vote):
2 if self.already_voted():
3 raise KeyError("You may not vote twice.")
4 vote = int(vote)
5 current_user = api.user.get_current()
6 self.annotations["voted"].append(current_user.id)
7 votes = self.annotations.get("votes", {})
8 if vote not in votes:
9 votes[vote] = 1
10 else:
11 votes[vote] += 1
12
13 def total_votes(self):
14 return sum(self.annotations.get("votes", {}).values())
15
16 def average_vote(self):
17 total_votes = sum(self.annotations.get("votes", {}).values())
18 if total_votes == 0:
19 return 0
20 total_points = sum([
21 vote * count for (vote, count) in self.annotations.get("votes", {}).items()
22 ])
23 return float(total_points) / total_votes
24
25 def has_votes(self):
26 return len(self.annotations.get("votes", {})) != 0
27
28 def already_voted(self):
29 current_user = api.user.get_current()
30 return current_user.id in self.annotations["voted"]
31
32 def clear(self):
33 annotations = IAnnotations(self.context)
34 annotations[KEY] = PersistentMapping({
35 "voted": PersistentList(),
36 "votes": PersistentMapping(),
37 })
38 self.annotations = annotations[KEY]
The already_voted method checks if the current user is saved in annotation value voted.
The vote method checks that the user did not already vote, then saves that the user did vote and saves the vote in the votes annotation value.
The methods total_votes and average_votes are self-explaining.
They calculate values that we want to use in a REST API endpoint.
The logic belongs to the behavior, not the service.
The method clear resets all votes.
The annotation of the context is set to an empty value like the __init__ method does.
Enable the behavior#
Enable the behavior 'ploneconf.votable.votable' on content type 'talk':
Back in mastering-plone-project, add the behavior in backend/src/ploneconf/site/profiles/default/types/talk.xml.
1 <property name="behaviors">
2 <element value="plone.dublincore" />
3 <element value="plone.namefromtitle" />
4 <element value="ploneconf.featured" />
5 <element value="plone.versioning" />
6 <element value="plone.eventbasic" />
7 <element value="plone.textindexer" />
8 <element value="ploneconf.votable.votable" />
9 </property>
Restart your backend and re-install the package ploneconf.site.