33.1. Complex behaviors [voting story] – Mastering Plone 6 development – 33. Roundtrip [The voting story] frontend, backend, and REST

Complex behaviors [voting story]

33.1. Complex behaviors [voting story]#

A group of jury members vote on talks to be accepted for the conference.

Backend chapter

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 being 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 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 you have been creating in the last chapter in your editor.

Later in your daily work you will 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 behaviors with an empty behaviors/__init__.py file.

To let Plone know about the behavior we are writing, 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 has been registered only with the provides attributes:

<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 gives access to the attributes we provide. A factory in Plone/Zope is an adapter, that means a function 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 with votable = IVotable(object). The expression IVotable(object) is short for "Get the appropriate adapter for interface IVotable and apply it to my object!". The result is an adopted object with the behavior features. You can for example get the value of votes by IVotable(object).votes. But you can not get the votes by object.votes, as the object does not know about votes. Only the adapted object IVotable(object) does know about votes.

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.

 1class IVotableMarker(Interface):
 2    """Marker interface for content types or instances that should be votable"""
 3
 4    pass
 5
 6
 7@provider(IFormFieldProvider)
 8class IVotable(model.Schema):
 9    """Behavior interface for the votable behavior
10
11    IVotable(object) returns the adapted object with votable behavior
12    """
13
14    votes = schema.Dict(
15        title="Vote info",
16        key_type=schema.TextLine(title="Voted number"),
17        value_type=schema.Int(title="Voted so often"),
18        default={},
19        missing_value={},
20        required=False,
21    )
22    voted = schema.List(
23        title="List of users who voted",
24        value_type=schema.TextLine(),
25        default=[],
26        missing_value=[],
27        required=False,
28    )
29
30    if not api.env.debug_mode():
31        form.omitted("votes")
32        form.omitted("voted")
33
34    directives.fieldset(
35        "debug",
36        label="debug",
37        fields=("votes", "voted"),
38    )
39
40    def vote(request):
41        """
42        Store the vote information and store the user(name)
43        to ensure that the user does not vote twice.
44        """
45
46    def average_vote():
47        """
48        Return the average voting for an item.
49        """
50
51    def has_votes():
52        """
53        Return whether anybody ever voted for this item.
54        """
55
56    def already_voted(request):
57        """
58        Return the information wether a person already voted.
59        """
60
61    def clear():
62        """
63        Clear the votes. Should only be called by admins.
64        """

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

 1from persistent.mapping import PersistentMapping
 2from persistent.list import PersistentList
 3
 4KEY = "training.votable.behaviors.votable.Votable"
 5
 6@implementer(IVotable)
 7@adapter(IVotableMarker)
 8class Votable(object):
 9    """Adapter implementing the votable behavior"""
10
11    def __init__(self, context):
12        self.context = context
13        annotations = IAnnotations(context)
14        if KEY not in annotations.keys():
15            # You know what happens if we don't use persistent classes here?
16            annotations[KEY] = PersistentMapping(
17                {"voted": PersistentList(), "votes": PersistentMapping()}
18            )
19        self.annotations = annotations[KEY]
20
21    # getter
22    @property
23    def votes(self):
24        return self.annotations["votes"]
25
26    # setter
27    # def votes(self, value):
28    #     """We do not define a setter.
29    #     Function 'vote' is the only one that shall set attributes
30    #     of the context object."""
31    #     self.annotations["votes"] = value
32
33    # getter
34    @property
35    def voted(self):
36        return self.annotations["voted"]
37
38    # setter
39    # def voted(self, value):
40    #     self.annotations["voted"] = value
41
42    def vote(self, vote, request):
43        if self.already_voted(request):
44            raise KeyError("You may not vote twice.")
45        vote = int(vote)
46        current_user = api.user.get_current()
47        self.annotations["voted"].append(current_user.id)
48        votes = self.annotations.get("votes", {})
49        if vote not in votes:
50            votes[vote] = 1
51        else:
52            votes[vote] += 1
53
54    def total_votes(self):
55        return sum(self.annotations.get("votes", {}).values())
56
57    def average_vote(self):
58        total_votes = sum(self.annotations.get("votes", {}).values())
59        if total_votes == 0:
60            return 0
61        total_points = sum(
62            [
63                vote * count
64                for (vote, count) in self.annotations.get("votes", {}).items()
65            ]
66        )
67        return float(total_points) / total_votes
68
69    def has_votes(self):
70        return len(self.annotations.get("votes", {})) != 0
71
72    def already_voted(self, request):
73        current_user = api.user.get_current()
74        return current_user.id in self.annotations["voted"]
75
76    def clear(self):
77        annotations = IAnnotations(self.context)
78        annotations[KEY] = PersistentMapping(
79            {"voted": PersistentList(), "votes": PersistentMapping()}
80        )
81        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 PersistentMapping and PersistentList. A PersistentMapping is simply an implementation of the Python dict type (via the standard library UserDict base class) adjusted for the persistence semantics of 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 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 behavior 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] = PersistentMapping(
38            {"voted": PersistentList(), "votes": PersistentMapping()}
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.

Enable the behavior#

Enable the behavior 'training.votable.votable' on content type 'talk':

Add the behavior in src/ploneconf/site/profiles/default/types/talk.xml.

  <property name="behaviors">
    <element value="plone.dublincore" />
    <element value="plone.namefromtitle" />
    <element value="plone.versioning" />
    <element value="ploneconf.featured" />
    <element value="plone.eventbasic" />
    <element value="plone.textindexer" />
    <element value="training.votable.votable" />
  </property>

Restart your backend and re-install the package ploneconf.site.