Complex Behaviors#
A group of jury members vote on talks to be accepted for the conference.
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
Schema and Annotation#
The talks are voted. So we provide an additional field with our behavior to store the votes on a talk. Therefore the behavior will have a schema with a field "votes".
We mark the field "votes" as an omitted field as this field should not be edited directly.
We are going to store the information about "votes" in an annotation
.
Imagine an add-on that unfortunately uses the same field name "votes" like we do for another purpose.
Here the AnnotationStorage comes in.
The content type instance is equipped by a storage where behaviors do store values with a key unique per behavior.
The Code#
Open your backend add-on created in last chapter in your editor.
To start, we create a directory behaviors
with an empty behaviors/__init__.py
file.
To let Plone know about the behavior we write, we include the behavior module:
1<configure xmlns="...">
2
3 ...
4 <include package=".behaviors" />
5 ...
6
7</configure>
Next, create a 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 <include package="plone.behavior" file="meta.zcml"/>
9
10 <plone:behavior
11 name="training.votable.votable"
12 title="Votable"
13 description="Support liking and disliking of content"
14 provides=".votable.IVotable"
15 factory=".votable.Votable"
16 marker=".votable.IVotableMarker"
17 />
18
19</configure>
There are important differences to the first simple behavior in Behaviors:
There is a marker interface
There is a factory
The first simple behavior (discussed in Behaviors) was registered only with the provides
attributes:
<plone:behavior
title="Featured"
name="ploneconf.featured"
description="Control if a item is shown on the frontpage"
provides=".featured.IFeatured"
/>
The factory
is a class that provides the behavior logic and gives access to the attributes we provide.
Factories in Plone/Zope are retrieved by adapting an object to an interface and are following the adapter pattern.
If you want to access your behavior features on an object, you would write votable = IVotable(object)
.
The marker
is introduced to register REST API endpoints for objects that adapts the behavior.
We now implement what we registered.
Therefore we create a file /behaviors/votable.py
with the schema, marker interface, and the factory.
1
2class IVotableMarker(Interface):
3 """Marker interface for content types or instances that should be votable"""
4
5 pass
6
7
8@provider(IFormFieldProvider)
9class IVotable(model.Schema):
10 """Behavior interface for the votable behavior
11
12 IVotable(object) returns the adapted object with votable behavior
13 """
14
15 if not api.env.debug_mode():
16 form.omitted("votes")
17 form.omitted("voted")
18
19 directives.fieldset(
20 "debug",
21 label="debug",
22 fields=("votes", "voted"),
23 )
24
25 votes = schema.Dict(
26 title="Vote info",
27 key_type=schema.TextLine(title="Voted number"),
28 value_type=schema.Int(title="Voted so often"),
29 default={},
30 missing_value={},
31 required=False,
32 )
33 voted = schema.List(
34 title="Vote hashes",
35 value_type=schema.TextLine(),
36 default=[],
37 missing_value=[],
38 required=False,
39 )
40
41 def vote(request):
42 """
43 Store the vote information, store the request hash to ensure
44 that the user does not vote twice
45 """
46
47 def average_vote():
48 """
49 Return the average voting for an item
50 """
51
52 def has_votes():
53 """
54 Return whether anybody ever voted for this item
55 """
56
57 def already_voted(request):
58 """
59 Return the information wether a person already voted.
60 This is not very high level and can be tricked out easily
61 """
62
63 def clear():
64 """
65 Clear the votes. Should only be called by admins
66 """
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 more complex, as you can see.
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 create two schema fields for our internal data structure. A dictionary to hold the votes given and a list to remember which jury members already voted and should not vote twice.
The directives form.omitted
from plone.autoform
allow us hide the fields.
The fields are there to save the data but should not be edited directly.
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 behaviors/votable.py
.
The factory is an adapter that adapts a talk to the behavior interface IVotable
.
1@implementer(IVotable)
2@adapter(IVotableMarker)
3class Votable(object):
4 """Adapter for the votable behavior
5
6 Args:
7 object (_type_): _description_
8 """
9
10 def __init__(self, context):
11 self.context = context
12 annotations = IAnnotations(context)
13 if KEY not in annotations.keys():
14 # You know what happens if we don't use persistent classes here?
15 annotations[KEY] = PersistentDict(
16 {"voted": PersistentList(), "votes": PersistentDict()}
17 )
18 self.annotations = annotations[KEY]
19
20 @property
21 def votes(self):
22 return self.annotations["votes"]
23
24 # @votes.setter
25 # def votes(self, value):
26 # self.annotations["votes"] = value
27
28 @property
29 def voted(self):
30 return self.annotations["voted"]
31
32 # @voted.setter
33 # def voted(self, value):
34 # self.annotations["voted"] = value
35
36 def vote(self, vote, request):
37 vote = int(vote)
38 if self.already_voted(request):
39 # Exceptions can create ugly error messages. If you or your user
40 # can't resolve the error, you should not catch it.
41 # Transactions can throw errors too.
42 # What happens if you catch them?
43 raise KeyError("You may not vote twice")
44 current_user = api.user.get_current()
45 self.annotations["voted"].append(current_user.id)
46 votes = self.annotations.get("votes", {})
47 if vote not in votes:
48 votes[vote] = 1
49 else:
50 votes[vote] += 1
51
52 def total_votes(self):
53 return sum(self.annotations.get("votes", {}).values())
54
55 def average_vote(self):
56 total_votes = sum(self.annotations.get("votes", {}).values())
57 if total_votes == 0:
58 return 0
59 total_points = sum(
60 [
61 vote * count
62 for (vote, count) in self.annotations.get("votes", {}).items()
63 ]
64 )
65 return float(total_points) / total_votes
66
67 def has_votes(self):
68 return len(self.annotations.get("votes", {})) != 0
69
70 def already_voted(self, request):
71 current_user = api.user.get_current()
72 return current_user.id in self.annotations["voted"]
73
74 def clear(self):
75 annotations = IAnnotations(self.context)
76 annotations[KEY] = PersistentDict(
77 {"voted": PersistentList(), "votes": PersistentDict()}
78 )
79 self.annotations = annotations[KEY]
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, cause the object is not already voted, we create it.
We work with PersistentDict
and PersistentList
.
Todo
Explain PersistentDict
and PersistentList
in short.
Next we provide the internal fields via properties. Using this form of property makes them read-only properties, as we do not define write handlers.
As you have seen in the Schema declaration, if you run your site in debug mode, you will see an edit field for these fields. But trying to change these fields will throw an exception.
Let's continue with the bahavior adapter:
1 def vote(self, vote, request):
2 if self.already_voted(request):
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 [
22 vote * count
23 for (vote, count) in self.annotations.get("votes", {}).items()
24 ]
25 )
26 return float(total_points) / total_votes
27
28 def has_votes(self):
29 return len(self.annotations.get("votes", {})) != 0
30
31 def already_voted(self, request):
32 current_user = api.user.get_current()
33 return current_user.id in self.annotations["voted"]
34
35 def clear(self):
36 annotations = IAnnotations(self.context)
37 annotations[KEY] = PersistentDict(
38 {"voted": PersistentList(), "votes": PersistentDict()}
39 )
40 self.annotations = annotations[KEY]
The voted
method stores names of users that already voted.
Whereas the already_voted
method checks if the user name is saved in annotation value voted
.
The vote
method requires a vote and a request.
We check the precondition that the user did not already vote, then we save that the user did vote and save his vote in 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
allows to reset votes.
Therefore the annotation of the context is set to an empty value like the __init__
method does.