Skip to page content or skip to Accesskey List.
Search evolt.org
evolt.org login: or register

Work

Main Page Content

Permission-based Content Notifications in Plone

Rated 4.3 (Ratings: 3) (Add your rating)

Log in to add a comment
(10 comments so far)

Want more?

 
Picture of MartinB

Martin Burns

Member info | Full bio

User since: April 26, 1999

Last login: December 28, 2008

Articles written: 127

A common requirement of sites with registered users or members is to be able to email different groups of members with information that might be of interest or use to them.

This might be with notifications of new content, matching users' preferences and interests, or it could be more generic send an email to all of such-and-such a group business requirement. In either case, you'll probably be interested in respecting the permissions your users have given you to contact them.

This will always mean only sending emails to people who have given you permission to do so, and often matching the emails to people whose interest you know matches the thing you want to send.

Because you're running your site professionally (you are, right?), you'll also want to send emails as part of a workflow. This means that only appropriate people will have the rights to send email, and you'll have an audit trail of who sent what, when. That way, if you get complaints of spam, you can quickly find out the facts (You gave us permission to do it and have an interest in the subject, or otherwise) and respond appropriately.

Business Requirements

For a system which announces new content on a site, our system requirements are basically:

  1. To be able to send email to registered users of the site.
  2. To enable users to give and retract permission for you to email them, and for your email dispatch to respect that permission.
  3. To enable users to register areas of interest, and for your email dispatch to only send email to users who are interested in areas which relate to what you propose to send.
  4. To have emails only sent out when the content is signed off for dispatch.
  5. To have the email contain
    1. A standard short message
    2. A paragraph of content-specific text (this will be a synopsis of the content, and is an existing data field in our content schema)
    3. A link to the content
  6. To have the person who sends the announcement (the actor in the workflow transition, in UML-speak) receive a notification of who the email announcement went to.

We could have a requirement that emails are sent on a schedule, and pick up all new content published in the last n hours. But for simplicity, that's not how I've chosen to do it. The normal thing to say here is that that's left as an exercise to the reader.

System

The system I'll use to demonstrate this is Plone - a Zope-based CMS which provides a rich API that provides hooks for adding user data elements and state & transition workflow scripting, making all of the following extremely simple.

I'm not going to cover installing Zope, Plone, or Plone sites here - there's some reasonable install documentation on the Plone site, with some handy all-in-one installers for Windows & Mac OSX.

This should also work on basic CMF, but is untested on that platform. Caveat Emptor.

Data Requirements

The basis of the solution is (pseudo-code):

If <strong>user</strong> has <strong>emailPermission</strong> and
    <strong>userInterest(any)</strong> matches <strong>contentKeyword(any)</strong>
    then <strong>sendEmail</strong>
    

Therefore, there are four key pieces of data:

Content Data

  1. Metadata Keywords
    Plone provides this out of the box, with a light Dublin Core implementation.

User Data

  1. Email Address
    Self-evidently, you need this. It's an existing user attribute in Plone, and is already required.
  2. Email Permission
    This is a simple user-settable boolean flag to say that the user grants permission to you to send them relevant email. Plone does not provide this out of the box, so we'll need to add it. We could use the Listed user property, but it's less confusing to keep them separate, and gives the user better control over their preferences.
  3. User Interests
    This is a list data-type, each element being an area that the user is interested in. If it's not there, they're not interested.

Implementation

Adding User Data Elements

Adding data elements to users in Plone is pretty simple (unlike adding elements to content, which is a whole other story). Plone keeps its user data schema in the portal_memberdata tool. In the ZMI, navigate to there, and select the Properties tab. This will give you a list of the currently available user properties, and their default values.

You need to add two new properties (all values is case-sensitive):

Property Name Property Type Default Value
interest lines null
emailPermission boolean false (ie unchecked)

The result is that all current users, and all new users, will have no interests registered, and have not granted you permission to email them. This is A Good Thing, as your emails will now be opt-in.

Enabling Data Entry

It's no use having data elements on each user data object if the users can't enter data into the waiting slots. So we need to customise the standard Plone form that users use to personalise their experience. This form can be found at /portal_skins/plone_forms/personalize_form.

If you're not used to customising CMF/Plone sites, you'll be worried that it's not editable. This is because it's looking at your server's file system (which Zope won't write to) for the data for this folder. To enable editing, you need to transfer the HTML file to the /portal_skins/custom folder, where you can edit it. There's a handy button on the locked form page, labelled Customize. Push it... you can now edit the form. How and why this works is beyond the scope of this article. For now, accept that it just does.

Once you've hit 'Customize', you'll find the HTML in a normal text-area form field. Again, don't worry that it appears not to have any of your site template in. The CMS is picking the main content out and inserting it into a template slot.

Enabling emailPermission Selection

Grab the HTML and drop it into your favourite HTML editor. Look for divs labelled thusly:

<div class="row">
    <div class="label">
        <span i18n:translate="label_listed_status">Listed status</span>

        <div id="listed_status_help"
             i18n:translate="help_listed_status" 
             class="help" 
             style="visibility:hidden">
        Select whether you want to be listed on the public membership listing
        or not.	Remember that your Member folder will still be publicly accessible unless
        you change its security settings, even if you select 'unlisted' here.
        </div>                
    </div>
    <div class="field"
       tal:define="listed python:request.get('listed', member.listed);
                     tabindex tabindex/next;">
        <input type="radio" 
               class="noborder" 
               name="listed" 
               value="on"
               id="cb_listed" 
               checked="checked"
               tabindex=""
               onfocus="formtooltip('listed_status_help',1)" 
               onblur="formtooltip('listed_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="listed" 
               />
               
        <input type="radio" 
               class="noborder" 
               name="listed" 
               value="on"
               id="cb_listed"
               tabindex=""
               onfocus="formtooltip('listed_status_help',1)" 
               onblur="formtooltip('listed_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="not: listed" 
               />
        
        <label for="cb_listed" i18n:translate="label_member_listed">Listed</label>
        
        <br />
        
        <input type="radio" 
               class="noborder" 
               name="listed" 
               value=""
               id="cb_unlisted"
               tabindex=""
               onfocus="formtooltip('listed_status_help',1)" 
               onblur="formtooltip('listed_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="listed" 
               />
        
        <input type="radio" 
               class="noborder" 
               name="listed" 
               value=""
               id="cb_unlisted" 
               checked="checked"
               tabindex=""
               onfocus="formtooltip('listed_status_help',1)" 
               onblur="formtooltip('listed_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="not: listed " 
               />		   
        
        <label for="cb_unlisted" i18n:translate="label_member_unlisted">Unlisted</label>
    </div>
</div>

(The indentation isn't significant, just useful)

This is the field for the Listed field. We're going to crib it somewhat to produce radio buttons that give the user an opt-in/out mechanism, selecting and deselecting the emailPermission data element.

Let's unpack that a bit.

&lt;div class=&quot;row&quot;&gt;

Each field is enclosed within a div with this class

&lt;div&nbsp;class=&quot;label&quot;&gt;<br>
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&lt;span&nbsp;i18n:translate=&quot;label_listed_status&quot;&gt;Listed&nbsp;status&lt;/span&gt;
     

The label class encapsulates both the field label and the dHTML tooltip. There's also support for auto-translation, but if you're using this, for your own fields, you'll need to add your own translations for the new content

&lt;div&nbsp;class=&quot;field&quot;<br>
&nbsp;&nbsp;&nbsp;tal:define=&quot;listed&nbsp;python:request.get('listed',&nbsp;member.listed);
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;tabindex&nbsp;tabindex/next;&quot;&gt;

Now we're into Zope Page Templating. We're setting variables with scope of this div, and the key one is getting the listed property out of this user's data.

Next up, we have a couple of radio buttons. Actually, we have code for two pairs of radio buttons, but there's some conditionalising going on, so only the ones which apply to the current state appear and have the appropriate selection data. Here's the button to make the user unlisted, with non-significant values removed:

<input type="radio" 
       name="listed" 
       value="on"
       checked="checked"
       tal:condition="listed" 
       />
<input type="radio" 
       name="listed" 
       value="on"
       tal:condition="not: listed" 
       />                   

We have a checked button which only appears if the member's listing property is set, and an unchecked one which only appears if the property is not set. For the other radio button, the values are reversed.

With all this knowledge, it should be fairly simple to construct our own radio button form field. Simply replace all references to listed with emailPermission (ie the data element name you added to the member), and reword the labelling. Here's my code - I've also added some more explanatory text as it's a sensitive issue:

<div class="row">
    <div class="label">
        Contact Permission
        <div id="permission_status_help"
             i18n:translate="help_emailPermission_status" 
             class="help" 
             style="visibility:hidden">
        Select whether you want us to send you relevant information
by email. 
        </div>                
    </div>
    <div style="margin:0px;">
        We would like to send you email, announcing new content that's relevant
        to your interests. Please select whether we have your permission to do this.
    </div>
    <div class="field"
       tal:define="emailPermission python:request.get('emailPermission', member.emailPermission);
                     tabindex tabindex/next;">
        <input type="radio" 
               class="noborder" 
               name="emailPermission" 
               value="on"
               id="cb_emailPermission" 
               checked="checked"
               tabindex=""
               onfocus="formtooltip('permission_status_help',1)" 
               onblur="formtooltip('permission_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="emailPermission" 
               />
               
        <input type="radio" 
               class="noborder" 
               name="emailPermission" 
               value="on"
               id="cb_emailPermission"
               tabindex=""
               onfocus="formtooltip('emailPermission_status_help',1)" 
               onblur="formtooltip('emailPermission_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="not: emailPermission" 
               />
        
        <label for="cb_emailPermission" i18n:translate="label_member_emailPermission">You may send alerts by email</label>
        
        <br />
        
        <input type="radio" 
               class="noborder" 
               name="emailPermission" 
               value=""
               id="cb_not_emailPermission"
               tabindex=""
               onfocus="formtooltip('emailPermission_status_help',1)" 
               onblur="formtooltip('emailPermission_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="emailPermission" 
               />
        
        <input type="radio" 
               class="noborder" 
               name="emailPermission" 
               value=""
               id="cb_not_emailPermission" 
               checked="checked"
               tabindex=""
               onfocus="formtooltip('emailPermission_status_help',1)" 
               onblur="formtooltip('emailPermission_status_help',0)"
               tal:attributes="tabindex tabindex;" 
               tal:condition="not: emailPermission " 
               />		   
        
        <label for="cb_not_emailPermission">You may <em>not</em> send alerts by email</label>
    </div>
</div>

Drop this in a suitable place in your customised form and test that your selection saves in your member data and is retrieved when you reload the form. I also added a customised member_search_results page to let me inspect all the member data while I was testing - you may find this useful too for diagnostics.

Enabling the Member Interest Selection

We're going to let members select their interests by means of checkboxes. This is a bit harder than radio buttons as we don't have existing form code to copy, but knowing how to retrieve data values, it's not hard.

Remember that the selections are going to end up as a list data type? Zope is going to help you out in a big way here. Zope has a wonderful shortcut to constructing list data - if you label your fields as: name=&quot;foo:list&quot; Zope will auto-magically bundle all the data together and make it available as a list called foo. Neat, eh?

So all we have to do to save the data is make sure that all our checkboxes are named interest:list and when we submit, we'll get a list saved in the member's interest data element.

Retrieving the data is also pretty simple. We're using a basic Python list function for testing whether a value is a member of a list, and if it is, we're writing in the checked attribute. I've only shown three checkboxes, but you can have as many as you like in your own layout. As long as they're within the &lt;div&gt;, it'll work fine.

<div class="row">
    <div class="label">Areas of Interest</div>
    <div class="field"
         tal:define="interestAreas python:request.get('interestAreas', member.interest)">
        <input type="checkbox" 
               name="interest:list" 
               value="advertising_promotion" 
               tal:attributes="checked python:test('advertising_promotion' in interestAreas, 'checked', '')" />
               Advertising & Promotion
        <input type="checkbox" 
               name="interest:list" 
               value="brand_marketing"
               tal:attributes="checked python:test('brand_marketing' in interestAreas, 'checked', '')" />
               Brand Marketing
        <input type="checkbox" 
               name="interest:list" 
               value="category_development"
               tal:attributes="checked python:test('category_development' in interestAreas, 'checked', '')" />
               Category Development
    </div>
</div>

Enabling the Content Metadata

This bit's really easy. Just take keywords you used in the checkboxes above and add them to the content, either through the properties tab when viewing Plone, or via the portal_metadata tool in the ZMI.

Note that these have to be exactly the field values you used for the checkboxes. An easy mistake is to use the field labels.

Workflow Scripting

Now that all the data's in place, we're on the home straight. All we need to do is add a script that compares the content and user data, checks that a member has given us permission to email them and fire off a few emails.

We'll do this in the standard workflow tool, which is the bundled DCWorkflow product. This is a 'states-and-transitions' type of workflow. You set up states that a content object can be in - with permissions attached to each state - and define transitions between those states. DCWorkflow also lets you apply scripts to execute before and/or after each transition, which is what we need.

Rather than simply adding a script to an existing transition, we're going to add a new state and transition specifically for email announcement. This will ensure that sending announcements is logged in the standard workflow audit trail, so we know whether a piece of content has been announced, and if so, when.

Adding a Workflow State

Go to the portal_workflow tool in the ZMI and select the Contents tab. This will give you the workflows that your site is currently using. Unless you've done any customisation already, you'll have 2: a folder workflow and a Plone workflow. The Plone workflow is the default one which controls most normal content, so it's this one we'll be editing.

Select that workflow and head to the States tab. Add a state called announced. This is the destination state that the content will be in after the emails have been sent. Set the permissions to a duplicate of the Published state. You need to make sure that your email recipients can view the content that you're announcing, so you'll want to set View and Access Contents Information permissions for Anonymous and Authenticated users, and once the content's announced, you don't want it being edited without further workflow, so only give the Manager role the permission of Modify Portal Content.

Next, select which transitions Announced content can then undergo. Again, I'd duplicate the Published state, and only permit the Reject and Retract transitions.

Adding a Workflow Transition

Back up to the Plone Workflow, and select the Transitions tab and add a new transition called 'announce'. The important properties to set are that there's a role guard - only the Manager role should be able to send email announcements - and in the 'Display in actions' box fields have a sensible name (eg Announce by email) and the category 'workflow'. Also make sure that the destination state is 'announced' and that the trigger is a user action. We'll add a workflow script after the script is set up.

Enabling the transition

Site managers will only be able to use the new transition if it's enabled as a permitted transition from an existing state. Go to the published state and add the 'announce' transition to the possible transitions available from the published state.

Adding a Workflow Script

Workflow scripts can be Python scripts, page templates, DTML documents or any other executable content you can add via the ZMI. We're going to use a Python script, so go to the workflow's Script tab and add a new script called email_announce.

This will take one parameter, review_state (which refers to the transition currently underway). Here's the script. Note that as with all Python coding, the indenting is significant.

#This script has been designed to send email to cmf users with appropriate
#preferences. The script should be used in conjunction with the workflow tool.  
#parameters review_state

# Set up a empty list of email addresses
# loop through the portal membership, pass memberId to check for
# Member role. If successful, check to see if the member has given
# permission to send email, and an area of business interest that 
# coincides with a content keyword. If successful, append the 
# list of email addresses and send them email

# Get the content object we're publishing
contentObject = review_state.object

# A nifty little function, which checks to see whether there are any elements
# that match between two lists, and returns the number of matches. Result: if
# the function returns 'true', you've got a match
def isIn(list1, list2):
     y=0
     for x in list1:
         if x in list2:
             y += 1
     return y

# Start with an empty list
mailList=[]

# Iterate through all the site's users
for item in context.portal_membership.listMembers():
    memberId = item.id
    
    # Remember that a real name is not mandatory, so fall back to the username
    if item.fullname:
        memberName = item.fullname
    else:
        memberName = memberId
    
    # Get a list of this member's interests...
    memberInterests = item.interest
    # ...and another that's the keywords of this object
    contentKeywords = contentObject.subject
    
    # Check to see if there's a match between the two
    isInterestedIn = isIn(memberInterests, contentKeywords)
    
    # This is the key condition:
    # If the user has the Member role and
    #             we have an email address and
    #             the user's interested in this content and
    #             we have permission to email them
    if 'Member' in context.portal_membership.getMemberById(memberId).getRoles() and (item.email !='') and isInterestedIn and item.emailPermission:
        # add them to the list of people we're emailing
        mailList.append(item.email)
        # check that we can send email via the Zope standard Mail Host
        try:
           mailhost=getattr(context, context.portal_url.superValues('Mail Host')[0].id)
        except:
           raise AttributeError, "Cannot find a Mail Host object"
       
        # Let's write an email:
        mMsg = 'Dear ' + memberName + ',\n\n'
        mMsg += 'We thought you\'d be interested in hearing about:\n'
        mMsg += contentObject.TitleOrId() + '\n\n'
        mMsg += 'Description: \n' + contentObject.Description() + '\n\n'
        mMsg += 'More info at:\n' + contentObject.absolute_url() + '\n'
        mTo = item.email
        mFrom = 'you@yoursite.com'
        mSubj = 'New Content available'

        # and send it
        mailhost.send(mMsg, mTo, mFrom, mSubj)

# The change in indentation signals the end of the loop, so we've
# now sent all the emails. Let's now send a confirmation that we've done it.

# We'll be building the email as a string again, but we have to convert our
# list data elements into a string before we can append the information
recipients = string.join(mailList, sep='\n')
keywordsString = string.join(contentKeywords, sep='\n')

mTo = 'you@yourdomain.com'
mMsg = 'The following people were sent a link to\n'
mMsg += contentObject.absolute_url() + '\n\n'
mMsg += recipients + '\n\n'
mMsg += 'The keywords were:\n' + keywordsString
mSubj = 'Content announcement email confirmation'
mailhost.send(mMsg, mTo, mFrom, mSubj)

Once you have the script set up, go back to your announce transition and select it in the 'before transition' slot - that way, you'll only complete the transition if the email all gets sent.

Summary

That looks a lot, but it's not that much really. What we've done is:

  1. Added a boolean email permission field to user data
  2. Added a list-type area of interest field to user data
  3. Amended the personalisation form so that users can store their preferences in the new fields
  4. Added appropriate keywords to content
  5. Added a new workflow state and transition to the workflow
  6. Added a script to select suitable members and send them email announcing the new content

With a bit of modification, this could be modified to allow the content metadata to also be matched against user roles, which would help the site management define user groups in addition to user self-selection.

Martin Burns has been doing this stuff since Netscape 1.0 days. Starting with the communication ends that online media support, he moved back through design, HTML and server-side code. Then he got into running the whole show. These days he's working for these people as a Project Manager, and still thinks (nearly 6 years on) it's a hell of a lot better than working for a dot-com. In his Copious Free Time™, he helps out running a Cloth Nappies online store.

Amongst his favourite things is ZopeDrupal, which he uses to run his personal site. He's starting to (re)gain a sneaking regard for ECMAscript since the arrival of unobtrusive scripting.

He's been a member of evolt.org since the very early days, a board member, a president, a writer and even contributed a modest amount of template code for the current site. Above all, he likes evolt.org to do things because it knowingly chooses to do so, rather than randomly stumbling into them. He's also one of the boys and girls who beervolts in the UK, although the arrival of small children in his life have knocked the frequency for 6.

Most likely to ask: Why would a client pay you to do that?

Least likely to ask: Why isn't that navigation frame in Flash?

Great overview, with tips on how to extend

Submitted by gerry_kirk on February 28, 2003 - 09:47.

Good balance of explanation with examples, plus links to where more info can be found. I am familiar with Plone, so it was easy for me to understand.

login or register to post comments

Small correction

Submitted by mkeller on March 4, 2003 - 04:28.

Very good article. When I tested it I found a small mistake in the example of "Enabling the Member Interest Selection":
in the 4th line you forgot to place the variable name
tal:define="python:request.get('interestAreas', member.interest)">
change to:
tal:define="interestAreas python:request.get('interestAreas', member.interest)">

login or register to post comments

Correction

Submitted by MartinB on March 5, 2003 - 16:39.

Many thanks, Marcel. I've amended the article

login or register to post comments

Audit trail

Submitted by h_askoe on March 13, 2003 - 07:40.

Great article! I have been looking for ways to dig into the workflow audit trail you mentioned but I haven't succeeded. Can I view the audit trail from the Plone user interface or the ZMI or do I need to write my own pages + python code?

login or register to post comments

Audit Trail

Submitted by MartinB on March 23, 2003 - 10:55.

The audit trail is available via the page's Content Status tab (ie you don't need the ZMI view) - it's standard for all workflow transitions. All transitions are datestamped, with the user who performed the transition and an optional comment.

login or register to post comments

Modification

Submitted by johnpaul on June 7, 2003 - 07:48.

Hi, I am trying to mmodify your exemple by having a dynamic list of preference. this would be in order to have different tabs depending on the interest group one belong to, or class one enrolled in.
My code for selecting preferences is the following:

<div class="row">
  <div> class="label">Classes enrolled in </div>
   <div class="field" tal:define="classesenrolledin python:request.get('classesenrolledin', member.enrolledin)">
   <table>
     <tr tal:repeat="class container/classes/objectValues">
     <td>
      <input type="checkbox"
          class="noborder"
          name="enrolledin:list"
          tal:attributes="value class/getId;
            checked python:test(class/getId in classesenrolledin, 'checked', nothing)">
     </td>
     <td>span tal:content="class/title">class title /span>/td>
     </tr>
   </table>
  </div>
</div>

.I can't figure out why the python:test does not seem to work
.How would I implement a class object that include a seperate forum, and other tools? should I look into the creation of a new portal_type?
Thanks for your advices.

login or register to post comments

Group notification?

Submitted by relisanhard52 on June 20, 2004 - 17:27.

Dear Martin, I am a plone newbie.. I need to send notification to a user group only. Can you send some tips. Also, I followed your article, I see that the 'announce by email' button (in state) appears in the Home page only. Can we not get this to display on all pages. Thanks Husain Kitabi

login or register to post comments

adding keywords

Submitted by dachterberg on June 29, 2004 - 20:28.

Leave it to me to not understand the really easy part, adding keywords "either through the properties tab when viewing Plone, or via the portal_metadata tool in the ZMI." My properties tab has no obvious property to enter keywords in, should I create one? I added the keywords under vocabulary in the Content Type under the Elements tab in portal_metadata in the ZMI. Is this correct?

login or register to post comments

Plone products

Submitted by iber on July 16, 2004 - 05:18.

Very nice article! Greate list of the ready for use Plone products can be found on content management software info

login or register to post comments

Martin, thanks for this

Submitted by deesto on January 9, 2007 - 14:21.

Martin, thanks for this tutorial, which seems to predate a similar article on plone.org ... interesting.

I was wondering whether it's still the case that such an elaborate solution is necessary for email notification in current Plone versions (2.5.1+)? Your solution is simple enough to follow, but being lazy I'd rather confirm its necessity before diving in. In addition, I'd like to confirm that the fix wouldn't break in later versions.

Thanks again.

login or register to post comments

The access keys for this page are: ALT (Control on a Mac) plus:

evolt.orgEvolt.org is an all-volunteer resource for web developers made up of a discussion list, a browser archive, and member-submitted articles. This article is the property of its author, please do not redistribute or use elsewhere without checking with the author.