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

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

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